diff --git a/.claude/commands/build.md b/.claude/commands/build.md new file mode 100644 index 00000000..89c42f1b --- /dev/null +++ b/.claude/commands/build.md @@ -0,0 +1,44 @@ +--- +description: Build jellypig tvOS (debug or release) +--- + +Build jellypig tvOS for the simulator. Takes an optional configuration argument: +- `debug` (default) - Fast build with debugging symbols +- `release` - Optimized build for distribution + +Usage: +- `/build` - Build in Debug configuration (default) +- `/build debug` - Build in Debug configuration (explicit) +- `/build release` - Build in Release configuration + +Steps to execute: +1. Parse the configuration argument (default to "debug" if not provided or invalid) +2. Validate the configuration is either "debug" or "release" (case-insensitive) +3. Run xcodebuild with the specified configuration: + ```bash + cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig + + # For debug: + xcodebuild -project jellypig.xcodeproj \ + -scheme "jellypig tvOS" \ + -sdk appletvsimulator \ + -configuration Debug \ + -derivedDataPath ./DerivedData \ + clean build \ + CODE_SIGNING_ALLOWED=NO + + # For release: + xcodebuild -project jellypig.xcodeproj \ + -scheme "jellypig tvOS" \ + -sdk appletvsimulator \ + -configuration Release \ + -derivedDataPath ./DerivedData \ + clean build \ + CODE_SIGNING_ALLOWED=NO + ``` +4. Report build status (success or failure) +5. Display the output path of the built app + +Expected output location: +- Debug: `./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app` +- Release: `./DerivedData/Build/Products/Release-appletvsimulator/jellypig tvOS.app` diff --git a/.claude/commands/init-dev.md b/.claude/commands/init-dev.md index 6fe79fc1..f6651f1c 100644 --- a/.claude/commands/init-dev.md +++ b/.claude/commands/init-dev.md @@ -8,9 +8,10 @@ Steps: 1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt 2. Display a concise summary including: - Project name and description - - Available custom slash commands (/sim, etc.) + - Available custom slash commands (/build, /sim, etc.) - Recent features implemented - - Key configuration details + - Key configuration details (Bundle ID, Simulator UUID, etc.) + - Build method: **Command-line builds work** via xcodebuild (no Xcode GUI required) - Common tasks you can help with Make the output brief and actionable - focus on what's immediately useful for the developer. diff --git a/.claude/commands/sim.md b/.claude/commands/sim.md index 7be1c180..06d52a62 100644 --- a/.claude/commands/sim.md +++ b/.claude/commands/sim.md @@ -1,14 +1,41 @@ --- -description: Build jellypig tvOS and launch in Apple TV simulator +description: Build and launch jellypig tvOS in simulator --- Build the latest version of jellypig tvOS in Debug configuration, install it on the Apple TV simulator, and launch it. Steps: -1. Boot the Apple TV simulator (16A71179-729D-4F1B-8698-8371F137025B) -2. Open Simulator.app -3. Build the project for tvOS Simulator -4. Install the built app on the simulator -5. Launch the app with bundle identifier org.ashik.jellypig +1. First, build the project using the same approach as `/build debug`: + ```bash + cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig + xcodebuild -project jellypig.xcodeproj \ + -scheme "jellypig tvOS" \ + -sdk appletvsimulator \ + -configuration Debug \ + -derivedDataPath ./DerivedData \ + clean build \ + CODE_SIGNING_ALLOWED=NO + ``` -Use xcodebuild to build, xcrun simctl to manage the simulator, and report success when the app is running. +2. Boot the Apple TV simulator: + ```bash + xcrun simctl boot 16A71179-729D-4F1B-8698-8371F137025B 2>/dev/null || true + ``` + +3. Open Simulator.app: + ```bash + open -a Simulator + ``` + +4. Install the built app on the simulator: + ```bash + xcrun simctl install 16A71179-729D-4F1B-8698-8371F137025B \ + "./DerivedData/Build/Products/Debug-appletvsimulator/jellypig tvOS.app" + ``` + +5. Launch the app: + ```bash + xcrun simctl launch 16A71179-729D-4F1B-8698-8371F137025B org.ashik.jellypig + ``` + +Report build and launch status. If any step fails, provide clear error message. diff --git a/Shared/Components/Layouts/FlowLayout.swift b/Shared/Components/Layouts/FlowLayout.swift new file mode 100644 index 00000000..f048b0ad --- /dev/null +++ b/Shared/Components/Layouts/FlowLayout.swift @@ -0,0 +1,364 @@ +// +// 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 + +/// A custom layout that arranges views in a flow pattern, automatically wrapping items to new rows +struct FlowLayout: Layout { + + // MARK: - Fill Direction + + enum Direction { + case up + case down + } + + // MARK: - Cache Structure + + struct CacheData { + let subviewSizes: [CGSize] + let rows: [[Int]] + let totalSize: CGSize + let lastWidth: CGFloat? + } + + // MARK: - Properties + + /// The alignment of content within each row (leading, center, or trailing) + private let alignment: HorizontalAlignment + /// Controls whether items fill from the top row down or bottom row up when wrapping + private let direction: Direction + /// The horizontal spacing between items within the same row + private let spacing: CGFloat + /// The vertical spacing between the top and bottom rows when content wraps + private let lineSpacing: CGFloat + /// The minimum number of items that must be in the smaller row when wrapping occurs + private let minRowLength: Int + + init( + alignment: HorizontalAlignment = .center, + direction: Direction = .up, + spacing: CGFloat = 8, + lineSpacing: CGFloat = 8, + minRowLength: Int = 2 + ) { + self.alignment = alignment + self.direction = direction + self.spacing = spacing + self.lineSpacing = lineSpacing + self.minRowLength = minRowLength + } + + // MARK: - Make Cache + + func makeCache(subviews: Subviews) -> CacheData { + CacheData( + subviewSizes: [], + rows: [], + totalSize: .zero, + lastWidth: nil + ) + } + + // MARK: - Update Cache + + func updateCache(_ cache: inout CacheData, subviews: Subviews) { + cache = CacheData( + subviewSizes: [], + rows: [], + totalSize: .zero, + lastWidth: nil + ) + } + + // MARK: - Calculate Layout + + private func calculateLayout( + subviews: Subviews, + width: CGFloat + ) -> (sizes: [CGSize], rows: [[Int]], totalSize: CGSize) { + let sizes = subviews.map { subview in + let size = subview.sizeThatFits(.unspecified) + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } + + let rows = computeRows(sizes: sizes, maxWidth: width) + let totalSize = computeTotalSize(rows: rows, sizes: sizes) + + return (sizes, rows, totalSize) + } + + // MARK: - Size That Fits + + /// Calculates the minimum size needed to display all subviews according to the flow layout rules + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout CacheData + ) -> CGSize { + let availableWidth = proposal.width ?? .infinity + let effectiveWidth = availableWidth.isFinite ? availableWidth : 1000 + + if cache.lastWidth != effectiveWidth || cache.subviewSizes.isEmpty { + let (sizes, rows, totalSize) = calculateLayout( + subviews: subviews, + width: effectiveWidth + ) + + cache = CacheData( + subviewSizes: sizes, + rows: rows, + totalSize: totalSize, + lastWidth: effectiveWidth + ) + } + + // Return the calculated height but respect the proposed width + return CGSize( + width: min(cache.totalSize.width, proposal.width ?? cache.totalSize.width), + height: cache.totalSize.height + ) + } + + // MARK: - Place Subviews + + /// Positions each subview within the given bounds according to the flow layout rules + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout CacheData + ) { + let availableWidth = bounds.width + + if cache.lastWidth != availableWidth || cache.subviewSizes.isEmpty { + let (sizes, rows, totalSize) = calculateLayout( + subviews: subviews, + width: availableWidth + ) + + cache = CacheData( + subviewSizes: sizes, + rows: rows, + totalSize: totalSize, + lastWidth: availableWidth + ) + } + + let sizes = cache.subviewSizes + let rows = cache.rows + + var yOffset: CGFloat = bounds.minY + + for row in rows { + let rowHeight = row.map { sizes[$0].height }.max() ?? 0 + let rowWidth = computeRowWidth(indices: row, sizes: sizes) + let xOffset = computeXOffset(rowWidth: rowWidth, bounds: bounds) + + var x = xOffset + for index in row { + let size = sizes[index] + let y = yOffset + (rowHeight - size.height) / 2 + + subviews[index].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + + x += size.width + spacing + } + + yOffset += rowHeight + lineSpacing + } + } + + // MARK: - Compute Rows + + /// Determines how to distribute items across rows based on the available width + private func computeRows( + sizes: [CGSize], + maxWidth: CGFloat + ) -> [[Int]] { + guard sizes.count > 1 else { + return sizes.isEmpty ? [] : [[0]] + } + + // First create rows by fitting items naturally + let rows = createInitialRows(sizes: sizes, maxWidth: maxWidth) + + // Then optimize distribution based on flow direction + return optimizeRowDistribution(rows: rows, sizes: sizes, maxWidth: maxWidth) + } + + /// Create initial rows by fitting items sequentially + private func createInitialRows( + sizes: [CGSize], + maxWidth: CGFloat + ) -> [[Int]] { + var rows: [[Int]] = [] + var currentRow: [Int] = [] + var currentWidth: CGFloat = 0 + + for (index, size) in sizes.enumerated() { + if currentRow.isEmpty { + currentRow.append(index) + currentWidth = size.width + } else { + let widthWithItem = currentWidth + spacing + size.width + + if widthWithItem <= maxWidth { + currentRow.append(index) + currentWidth = widthWithItem + } else { + rows.append(currentRow) + currentRow = [index] + currentWidth = size.width + } + } + } + + if !currentRow.isEmpty { + rows.append(currentRow) + } + + return rows + } + + /// Optimize row distribution based on flow direction + private func optimizeRowDistribution( + rows: [[Int]], + sizes: [CGSize], + maxWidth: CGFloat + ) -> [[Int]] { + guard rows.count > 1 else { return rows } + + var optimizedRows = rows + + switch direction { + case .up: + // Move items from earlier rows to later rows to create upward flow + optimizedRows = balanceRowsForUpwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth) + case .down: + // Move items from later rows to earlier rows to create downward flow + optimizedRows = balanceRowsForDownwardFlow(rows: optimizedRows, sizes: sizes, maxWidth: maxWidth) + } + + return optimizedRows + } + + /// Balance rows for upward flow - fill bottom rows more than top rows + private func balanceRowsForUpwardFlow( + rows: [[Int]], + sizes: [CGSize], + maxWidth: CGFloat + ) -> [[Int]] { + var optimizedRows = rows + + for i in 0 ..< optimizedRows.count - 1 { + while optimizedRows[i].count > minRowLength { + let lastItem = optimizedRows[i].last! + + var testRow = optimizedRows[i + 1] + testRow.append(lastItem) + let newWidth = computeRowWidth(indices: testRow, sizes: sizes) + + if newWidth <= maxWidth { + optimizedRows[i].removeLast() + optimizedRows[i + 1].append(lastItem) + } else { + break + } + } + } + + return optimizedRows + } + + /// Balance rows for downward flow - fill top rows more than bottom rows + private func balanceRowsForDownwardFlow( + rows: [[Int]], + sizes: [CGSize], + maxWidth: CGFloat + ) -> [[Int]] { + var optimizedRows = rows + + for i in (0 ..< optimizedRows.count - 1).reversed() { + while optimizedRows[i + 1].count > minRowLength { + let firstItem = optimizedRows[i + 1].first! + + var testRow = optimizedRows[i] + testRow.append(firstItem) + let newWidth = computeRowWidth(indices: testRow, sizes: sizes) + + if newWidth <= maxWidth { + optimizedRows[i + 1].removeFirst() + optimizedRows[i].append(firstItem) + } else { + break + } + } + } + + return optimizedRows + } + + // MARK: - Compute Row Width + + /// Calculates the total width needed for a row of items including spacing + private func computeRowWidth( + indices: [Int], + sizes: [CGSize] + ) -> CGFloat { + guard indices.isNotEmpty else { return 0 } + + let itemsWidth = indices.reduce(0) { $0 + sizes[$1].width } + let spacingWidth = spacing * CGFloat(indices.count - 1) + + return itemsWidth + spacingWidth + } + + // MARK: - Compute X Offset + + /// Calculates the starting X position for a row based on the alignment setting + private func computeXOffset( + rowWidth: CGFloat, + bounds: CGRect + ) -> CGFloat { + switch alignment { + case .trailing: + return bounds.maxX - rowWidth + case .center: + return bounds.minX + (bounds.width - rowWidth) / 2 + default: + return bounds.minX + } + } + + // MARK: - Compute Total Size + + /// Calculates the total size needed to display all rows with proper spacing + private func computeTotalSize( + rows: [[Int]], + sizes: [CGSize] + ) -> CGSize { + guard rows.isNotEmpty else { return .zero } + + let rowHeights = rows.map { row in + row.map { sizes[$0].height }.max() ?? 0 + } + + let totalHeight = rowHeights.reduce(0, +) + lineSpacing * CGFloat(rows.count - 1) + + let maxWidth = rows.map { row in + computeRowWidth(indices: row, sizes: sizes) + }.max() ?? 0 + + return CGSize(width: maxWidth, height: totalHeight) + } +} diff --git a/Shared/Components/Localization/CountryPicker.swift b/Shared/Components/Localization/CountryPicker.swift new file mode 100644 index 00000000..dae45a5d --- /dev/null +++ b/Shared/Components/Localization/CountryPicker.swift @@ -0,0 +1,131 @@ +// +// 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 + +struct CountryPicker: View { + + // MARK: - State Objects + + @StateObject + private var viewModel: CountriesViewModel + + // MARK: - Input Properties + + private var selectionBinding: Binding + private let title: String + + @State + private var selection: CountryInfo? + + // MARK: - Body + + var body: some View { + Group { + #if os(tvOS) + ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) { + Picker(title, selection: $selection) { + Text(CountryInfo.none.displayTitle) + .tag(CountryInfo.none as CountryInfo?) + + ForEach(viewModel.value, id: \.self) { country in + Text(country.displayTitle) + .tag(country as CountryInfo?) + } + } + } + // TODO: iOS 17+ move this to the Group + .onChange(of: viewModel.value) { + updateSelection() + } + .onChange(of: selection) { _, newValue in + selectionBinding.wrappedValue = newValue + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + #else + Picker(title, selection: $selection) { + + Text(CountryInfo.none.displayTitle) + .tag(CountryInfo.none as CountryInfo?) + + ForEach(viewModel.value, id: \.self) { country in + Text(country.displayTitle) + .tag(country as CountryInfo?) + } + } + // TODO: iOS 17+ delete this and use the tvOS onChange at the Group level + .onChange(of: viewModel.value) { _ in + updateSelection() + } + .onChange(of: selection) { newValue in + selectionBinding.wrappedValue = newValue + } + #endif + } + .onFirstAppear { + viewModel.refresh() + } + } + + private func updateSelection() { + let newValue = viewModel.value.first { value in + if let selectedTwo = selection?.twoLetterISORegionName, + let candidateTwo = value.twoLetterISORegionName, + selectedTwo == candidateTwo + { + return true + } + if let selectedThree = selection?.threeLetterISORegionName, + let candidateThree = value.threeLetterISORegionName, + selectedThree == candidateThree + { + return true + } + return false + } + + selection = newValue ?? CountryInfo.none + } +} + +extension CountryPicker { + + init(_ title: String, twoLetterISORegion: Binding) { + self.title = title + self._selection = State( + initialValue: twoLetterISORegion.wrappedValue.flatMap { code in + CountryInfo( + name: code, + twoLetterISORegionName: code + ) + } ?? CountryInfo.none + ) + self.selectionBinding = Binding( + get: { + guard let code = twoLetterISORegion.wrappedValue else { + return CountryInfo.none + } + return CountryInfo( + name: code, + twoLetterISORegionName: code + ) + }, + set: { newCountry in + twoLetterISORegion.wrappedValue = newCountry?.twoLetterISORegionName + } + ) + + self._viewModel = StateObject( + wrappedValue: CountriesViewModel( + initialValue: [.none] + ) + ) + } +} diff --git a/Shared/Components/Localization/CulturePicker.swift b/Shared/Components/Localization/CulturePicker.swift new file mode 100644 index 00000000..6950466d --- /dev/null +++ b/Shared/Components/Localization/CulturePicker.swift @@ -0,0 +1,153 @@ +// +// 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 + +struct CulturePicker: View { + + // MARK: - State Objects + + @StateObject + private var viewModel: CulturesViewModel + + // MARK: - Input Properties + + private var selectionBinding: Binding + private let title: String + + @State + private var selection: CultureDto? + + // MARK: - Body + + var body: some View { + Group { + #if os(tvOS) + ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) { + Picker(title, selection: $selection) { + Text(CultureDto.none.displayTitle) + .tag(CultureDto.none as CultureDto?) + + ForEach(viewModel.value, id: \.self) { value in + Text(value.displayTitle) + .tag(value as CultureDto?) + } + } + } + // TODO: iOS 17+ move this to the Group + .onChange(of: viewModel.value) { + updateSelection() + } + .onChange(of: selection) { _, newValue in + selectionBinding.wrappedValue = newValue + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + #else + Picker(title, selection: $selection) { + + Text(CultureDto.none.displayTitle) + .tag(CultureDto.none as CultureDto?) + + ForEach(viewModel.value, id: \.self) { value in + Text(value.displayTitle) + .tag(value as CultureDto?) + } + } + // TODO: iOS 17+ delete this and use the tvOS onChange at the Group level + .onChange(of: viewModel.value) { _ in + updateSelection() + } + .onChange(of: selection) { newValue in + selectionBinding.wrappedValue = newValue + } + #endif + } + .onFirstAppear { + viewModel.refresh() + } + } + + private func updateSelection() { + let newValue = viewModel.value.first { value in + if let selectedTwo = selection?.twoLetterISOLanguageName, + let candidateTwo = value.twoLetterISOLanguageName, + selectedTwo == candidateTwo + { + return true + } + if let selectedThree = selection?.threeLetterISOLanguageName, + let candidateThree = value.threeLetterISOLanguageName, + selectedThree == candidateThree + { + return true + } + return false + } + + selection = newValue ?? CultureDto.none + } +} + +extension CulturePicker { + + init(_ title: String, twoLetterISOLanguageName: Binding) { + self.title = title + self._selection = State( + initialValue: twoLetterISOLanguageName.wrappedValue.flatMap { + CultureDto(twoLetterISOLanguageName: $0) + } ?? CultureDto.none + ) + + self.selectionBinding = Binding( + get: { + guard let code = twoLetterISOLanguageName.wrappedValue else { + return CultureDto.none + } + return CultureDto(twoLetterISOLanguageName: code) + }, + set: { newCountry in + twoLetterISOLanguageName.wrappedValue = newCountry?.twoLetterISOLanguageName + } + ) + + self._viewModel = StateObject( + wrappedValue: CulturesViewModel( + initialValue: [.none] + ) + ) + } + + init(_ title: String, threeLetterISOLanguageName: Binding) { + self.title = title + self._selection = State( + initialValue: threeLetterISOLanguageName.wrappedValue.flatMap { + CultureDto(threeLetterISOLanguageName: $0) + } ?? CultureDto.none + ) + + self.selectionBinding = Binding( + get: { + guard let code = threeLetterISOLanguageName.wrappedValue else { + return CultureDto.none + } + return CultureDto(threeLetterISOLanguageName: code) + }, + set: { newCountry in + threeLetterISOLanguageName.wrappedValue = newCountry?.threeLetterISOLanguageName + } + ) + + self._viewModel = StateObject( + wrappedValue: CulturesViewModel( + initialValue: [.none] + ) + ) + } +} diff --git a/Shared/Components/Localization/ParentalRatingPicker.swift b/Shared/Components/Localization/ParentalRatingPicker.swift new file mode 100644 index 00000000..77a29d82 --- /dev/null +++ b/Shared/Components/Localization/ParentalRatingPicker.swift @@ -0,0 +1,120 @@ +// +// 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 + +struct ParentalRatingPicker: View { + + // MARK: - State Objects + + @StateObject + private var viewModel: ParentalRatingsViewModel + + // MARK: - Input Properties + + private var selectionBinding: Binding + private let title: String + + @State + private var selection: ParentalRating? + + // MARK: - Body + + var body: some View { + Group { + #if os(tvOS) + ListRowMenu(title, subtitle: $selection.wrappedValue?.displayTitle) { + Picker(title, selection: $selection) { + Text(ParentalRating.none.displayTitle) + .tag(ParentalRating.none as ParentalRating?) + + ForEach(viewModel.value, id: \.self) { value in + Text(value.displayTitle) + .tag(value as ParentalRating?) + } + } + } + .onChange(of: viewModel.value) { + updateSelection() + } + .onChange(of: selection) { _, newValue in + selectionBinding.wrappedValue = newValue + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + #else + Picker(title, selection: $selection) { + + Text(ParentalRating.none.displayTitle) + .tag(ParentalRating.none as ParentalRating?) + + ForEach(viewModel.value, id: \.self) { value in + Text(value.displayTitle) + .tag(value as ParentalRating?) + } + } + .onChange(of: viewModel.value) { _ in + updateSelection() + } + .onChange(of: selection) { newValue in + selectionBinding.wrappedValue = newValue + } + #endif + } + .onFirstAppear { + viewModel.refresh() + } + } + + // MARK: - Update Selection + + private func updateSelection() { + let newValue = viewModel.value.first { value in + if let selectedName = selection?.name, + let candidateName = value.name, + selectedName == candidateName + { + return true + } + return false + } + + selection = newValue ?? ParentalRating.none + } +} + +extension ParentalRatingPicker { + + init(_ title: String, name: Binding) { + self.title = title + self._selection = State( + initialValue: name.wrappedValue.flatMap { + ParentalRating(name: $0) + } ?? ParentalRating.none + ) + + self.selectionBinding = Binding( + get: { + guard let ratingName = name.wrappedValue else { + return ParentalRating.none + } + return ParentalRating(name: ratingName) + }, + set: { newRating in + name.wrappedValue = newRating?.name + } + ) + + self._viewModel = StateObject( + wrappedValue: ParentalRatingsViewModel( + initialValue: [.none] + ) + ) + } +} diff --git a/Shared/Components/MarkedList.swift b/Shared/Components/MarkedList.swift new file mode 100644 index 00000000..f93a3423 --- /dev/null +++ b/Shared/Components/MarkedList.swift @@ -0,0 +1,91 @@ +// +// 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 + +/// A `VStack` that displays subviews with a marker on the top leading edge. +/// +/// In a marker view, ensure that views that are only used for layout are +/// tagged with `hidden` to avoid them being read by accessibility features. +struct MarkedList: View { + + private let content: Content + private let marker: (Int) -> Marker + private let spacing: CGFloat + + init( + spacing: CGFloat, + @ViewBuilder marker: @escaping (Int) -> Marker, + @ViewBuilder content: @escaping () -> Content + ) { + self.marker = marker + self.content = content() + self.spacing = spacing + } + + var body: some View { + _VariadicView.Tree( + MarkedListLayout( + spacing: spacing, + marker: marker + ) + ) { + content + } + } +} + +extension MarkedList { + + struct MarkedListLayout: _VariadicView_UnaryViewRoot { + + let spacing: CGFloat + let marker: (Int) -> Marker + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(Array(zip(children.indices, children)), id: \.0) { child in + MarkedListEntry( + marker: marker(child.0), + content: child.1 + ) + } + } + } + } + + struct MarkedListEntry: View { + + @State + private var markerSize: CGSize = .zero + @State + private var childSize: CGSize = .zero + + let marker: Marker + let content: EntryContent + + private var _bullet: some View { + marker + .trackingSize($markerSize) + } + + // TODO: this can cause clipping issues with text since + // with .offset, find fix + var body: some View { + ZStack { + content + .trackingSize($childSize) + .overlay(alignment: .topLeading) { + _bullet + .offset(x: -markerSize.width) + } + } + } + } +} diff --git a/Shared/Components/NativeVideoPlayer.swift b/Shared/Components/NativeVideoPlayer.swift new file mode 100644 index 00000000..b483afe0 --- /dev/null +++ b/Shared/Components/NativeVideoPlayer.swift @@ -0,0 +1,111 @@ +// +// 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 AVKit +import Factory +import JellyfinAPI +import Logging +import SwiftUI + +// TODO: remove + +struct NativeVideoPlayer: View { + + @Environment(\.presentationCoordinator) + private var presentationCoordinator + + @InjectedObject(\.mediaPlayerManager) + private var manager: MediaPlayerManager + + @LazyState + private var proxy: AVMediaPlayerProxy + + @Router + private var router + + init() { + self._proxy = .init(wrappedValue: AVMediaPlayerProxy()) + } + + var body: some View { + ZStack { + + Color.black + + switch manager.state { + case .playback: + NativeVideoPlayerView(proxy: proxy) + default: + ProgressView() + } + } + .onAppear { + manager.proxy = proxy + manager.start() + } + .preference(key: IsStatusBarHiddenKey.self, value: true) + .backport + .onChange(of: presentationCoordinator.isPresented) { _, isPresented in + Container.shared.mediaPlayerManager.reset() + guard !isPresented else { return } + manager.stop() + } + .alert( + L10n.error, + isPresented: .constant(manager.error != nil) + ) { + Button(L10n.close, role: .cancel) { + Container.shared.mediaPlayerManager.reset() + router.dismiss() + } + } message: { + // TODO: localize + Text("Unable to load this item.") + } + } +} + +extension NativeVideoPlayer { + + private struct NativeVideoPlayerView: UIViewControllerRepresentable { + + let proxy: AVMediaPlayerProxy + + func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController { + UINativeVideoPlayerViewController(proxy: proxy) + } + + func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {} + } + + private class UINativeVideoPlayerViewController: AVPlayerViewController { + + private let proxy: AVMediaPlayerProxy + + init(proxy: AVMediaPlayerProxy) { + self.proxy = proxy + + super.init(nibName: nil, bundle: nil) + + player = proxy.player + + player?.allowsExternalPlayback = true + player?.appliesMediaSelectionCriteriaAutomatically = false + allowsPictureInPicturePlayback = true + + #if !os(tvOS) + updatesNowPlayingInfoCenter = false + #endif + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } +} diff --git a/Shared/Components/PosterImage.swift b/Shared/Components/PosterImage.swift new file mode 100644 index 00000000..0bde58e4 --- /dev/null +++ b/Shared/Components/PosterImage.swift @@ -0,0 +1,90 @@ +// +// 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 BlurHashKit +import SwiftUI + +/// Retrieving images by exact pixel dimensions is a bit +/// intense for normal usage and eases cache usage and modifications. +private let landscapeMaxWidth: CGFloat = 300 +private let portraitMaxWidth: CGFloat = 200 + +struct PosterImage: View { + + private let contentMode: ContentMode + private let imageMaxWidth: CGFloat + private let item: Item + private let type: PosterDisplayType + + init( + item: Item, + type: PosterDisplayType, + contentMode: ContentMode = .fill, + maxWidth: CGFloat? = nil + ) { + self.contentMode = contentMode + self.imageMaxWidth = maxWidth ?? (type == .landscape ? landscapeMaxWidth : portraitMaxWidth) + self.item = item + self.type = type + } + + private var imageSources: [ImageSource] { + switch type { + case .landscape: + item.landscapeImageSources(maxWidth: imageMaxWidth, quality: 90) + case .portrait: + item.portraitImageSources(maxWidth: imageMaxWidth, quality: 90) + case .square: + item.squareImageSources(maxWidth: imageMaxWidth, quality: 90) + } + } + + var body: some View { + ZStack { + Rectangle() + .fill(.complexSecondary) + + AlternateLayoutView { + Color.clear + } content: { + ImageView(imageSources) + .image(item.transform) + .placeholder { imageSource in + if let blurHash = imageSource.blurHash { + BlurHashView(blurHash: blurHash) + } else if item.showTitle { + SystemImageContentView( + systemName: item.systemImage + ) + } else { + SystemImageContentView( + title: item.displayTitle, + systemName: item.systemImage + ) + } + } + .failure { + if item.showTitle { + SystemImageContentView( + systemName: item.systemImage + ) + } else { + SystemImageContentView( + title: item.displayTitle, + systemName: item.systemImage + ) + } + } + } + } + .posterStyle( + type, + contentMode: contentMode + ) + } +} diff --git a/Shared/Components/TintedMaterial.swift b/Shared/Components/TintedMaterial.swift new file mode 100644 index 00000000..7075c800 --- /dev/null +++ b/Shared/Components/TintedMaterial.swift @@ -0,0 +1,27 @@ +// +// 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 TintedMaterial: UIViewRepresentable { + + let tint: Color + + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: .extraLight)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + set(tint: tint, for: uiView) + } + + private func set(tint: Color, for view: UIVisualEffectView) { + let overlayView = view.subviews.first { type(of: $0) == NSClassFromString("_UIVisualEffectSubview") } + overlayView?.backgroundColor = UIColor(tint.opacity(0.75)) + } +} diff --git a/Shared/Coordinators/AppSettingsCoordinator 2.swift b/Shared/Coordinators/AppSettingsCoordinator 2.swift new file mode 100644 index 00000000..63237232 --- /dev/null +++ b/Shared/Coordinators/AppSettingsCoordinator 2.swift @@ -0,0 +1,72 @@ +// +// 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 PulseUI +import Stinsen +import SwiftUI + +final class AppSettingsCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \AppSettingsCoordinator.start) + + @Root + var start = makeStart + + #if os(iOS) + @Route(.push) + var about = makeAbout + @Route(.push) + var appIconSelector = makeAppIconSelector + @Route(.push) + var log = makeLog + #endif + + #if os(tvOS) + @Route(.push) + var log = makeLog + + @Route(.fullScreen) + var hourPicker = makeHourPicker + #endif + + init() {} + + #if os(iOS) + @ViewBuilder + func makeAbout(viewModel: SettingsViewModel) -> some View { + AboutAppView(viewModel: viewModel) + } + + @ViewBuilder + func makeAppIconSelector(viewModel: SettingsViewModel) -> some View { + AppIconSelectorView(viewModel: viewModel) + } + #endif + + @ViewBuilder + func makeLog() -> some View { + ConsoleView() + } + + @ViewBuilder + func makeStart() -> some View { + AppSettingsView() + } + + #if os(tvOS) + @ViewBuilder + func makeHourPicker() -> some View { + ZStack { + BlurView() + .ignoresSafeArea() + + HourMinutePicker() + } + } + #endif +} diff --git a/Shared/Coordinators/AppSettingsCoordinator 3.swift b/Shared/Coordinators/AppSettingsCoordinator 3.swift new file mode 100644 index 00000000..63237232 --- /dev/null +++ b/Shared/Coordinators/AppSettingsCoordinator 3.swift @@ -0,0 +1,72 @@ +// +// 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 PulseUI +import Stinsen +import SwiftUI + +final class AppSettingsCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \AppSettingsCoordinator.start) + + @Root + var start = makeStart + + #if os(iOS) + @Route(.push) + var about = makeAbout + @Route(.push) + var appIconSelector = makeAppIconSelector + @Route(.push) + var log = makeLog + #endif + + #if os(tvOS) + @Route(.push) + var log = makeLog + + @Route(.fullScreen) + var hourPicker = makeHourPicker + #endif + + init() {} + + #if os(iOS) + @ViewBuilder + func makeAbout(viewModel: SettingsViewModel) -> some View { + AboutAppView(viewModel: viewModel) + } + + @ViewBuilder + func makeAppIconSelector(viewModel: SettingsViewModel) -> some View { + AppIconSelectorView(viewModel: viewModel) + } + #endif + + @ViewBuilder + func makeLog() -> some View { + ConsoleView() + } + + @ViewBuilder + func makeStart() -> some View { + AppSettingsView() + } + + #if os(tvOS) + @ViewBuilder + func makeHourPicker() -> some View { + ZStack { + BlurView() + .ignoresSafeArea() + + HourMinutePicker() + } + } + #endif +} diff --git a/Shared/Coordinators/HomeCoordinator 2.swift b/Shared/Coordinators/HomeCoordinator 2.swift new file mode 100644 index 00000000..9e0ee888 --- /dev/null +++ b/Shared/Coordinators/HomeCoordinator 2.swift @@ -0,0 +1,45 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class HomeCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \HomeCoordinator.start) + + @Root + var start = makeStart + + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #endif + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + @ViewBuilder + func makeStart() -> some View { + HomeView() + } +} diff --git a/Shared/Coordinators/HomeCoordinator 3.swift b/Shared/Coordinators/HomeCoordinator 3.swift new file mode 100644 index 00000000..9e0ee888 --- /dev/null +++ b/Shared/Coordinators/HomeCoordinator 3.swift @@ -0,0 +1,45 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class HomeCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \HomeCoordinator.start) + + @Root + var start = makeStart + + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #endif + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + @ViewBuilder + func makeStart() -> some View { + HomeView() + } +} diff --git a/Shared/Coordinators/LibraryCoordinator 2.swift b/Shared/Coordinators/LibraryCoordinator 2.swift new file mode 100644 index 00000000..f13fbe9f --- /dev/null +++ b/Shared/Coordinators/LibraryCoordinator 2.swift @@ -0,0 +1,60 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LibraryCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LibraryCoordinator.start) + + @Root + var start = makeStart + + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + @Route(.modal) + var filter = makeFilter + #endif + + private let viewModel: PagingLibraryViewModel + + init(viewModel: PagingLibraryViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder + func makeStart() -> some View { + PagingLibraryView(viewModel: viewModel) + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + #if !os(tvOS) + func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) + } + #endif +} diff --git a/Shared/Coordinators/LibraryCoordinator 3.swift b/Shared/Coordinators/LibraryCoordinator 3.swift new file mode 100644 index 00000000..f13fbe9f --- /dev/null +++ b/Shared/Coordinators/LibraryCoordinator 3.swift @@ -0,0 +1,60 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LibraryCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LibraryCoordinator.start) + + @Root + var start = makeStart + + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + @Route(.modal) + var filter = makeFilter + #endif + + private let viewModel: PagingLibraryViewModel + + init(viewModel: PagingLibraryViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder + func makeStart() -> some View { + PagingLibraryView(viewModel: viewModel) + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + #if !os(tvOS) + func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) + } + #endif +} diff --git a/Shared/Coordinators/LiveVideoPlayerCoordinator.swift b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift index cda151b2..af75ecb3 100644 --- a/Shared/Coordinators/LiveVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift @@ -69,6 +69,8 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable { #else PreferencesView { + // Use VLC for Live TV to handle raw MPEG-TS streams from Dispatcharr + // (Native AVPlayer can't play raw MPEG-TS, only HLS) if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { LiveVideoPlayer(manager: self.videoPlayerManager) } else { diff --git a/Shared/Coordinators/MediaCoordinator 2.swift b/Shared/Coordinators/MediaCoordinator 2.swift new file mode 100644 index 00000000..8322daee --- /dev/null +++ b/Shared/Coordinators/MediaCoordinator 2.swift @@ -0,0 +1,56 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class MediaCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \MediaCoordinator.start) + + @Root + var start = makeStart + #if os(tvOS) + @Route(.fullScreen) + var library = makeLibrary + @Route(.fullScreen) + var liveTV = makeLiveTV + #else + @Route(.push) + var library = makeLibrary + @Route(.push) + var liveTV = makeLiveTV + @Route(.push) + var downloads = makeDownloads + #endif + + #if os(tvOS) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) + } + #else + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + func makeDownloads() -> DownloadListCoordinator { + DownloadListCoordinator() + } + #endif + + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + + @ViewBuilder + func makeStart() -> some View { + MediaView() + } +} diff --git a/Shared/Coordinators/MediaCoordinator 3.swift b/Shared/Coordinators/MediaCoordinator 3.swift new file mode 100644 index 00000000..8322daee --- /dev/null +++ b/Shared/Coordinators/MediaCoordinator 3.swift @@ -0,0 +1,56 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class MediaCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \MediaCoordinator.start) + + @Root + var start = makeStart + #if os(tvOS) + @Route(.fullScreen) + var library = makeLibrary + @Route(.fullScreen) + var liveTV = makeLiveTV + #else + @Route(.push) + var library = makeLibrary + @Route(.push) + var liveTV = makeLiveTV + @Route(.push) + var downloads = makeDownloads + #endif + + #if os(tvOS) + func makeLibrary(viewModel: PagingLibraryViewModel) -> NavigationViewCoordinator> { + NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel)) + } + #else + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + func makeDownloads() -> DownloadListCoordinator { + DownloadListCoordinator() + } + #endif + + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + + @ViewBuilder + func makeStart() -> some View { + MediaView() + } +} diff --git a/Shared/Coordinators/Navigation/NavigationCoordinator.swift b/Shared/Coordinators/Navigation/NavigationCoordinator.swift new file mode 100644 index 00000000..cbc8250b --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationCoordinator.swift @@ -0,0 +1,47 @@ +// +// 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 + +@MainActor +final class NavigationCoordinator: ObservableObject { + + @Published + var path: [NavigationRoute] = [] + + @Published + var presentedSheet: NavigationRoute? + @Published + var presentedFullScreen: NavigationRoute? + + func push( + _ route: NavigationRoute + ) { + let style = route.transitionStyle + + #if os(tvOS) + switch style { + case .push, .sheet: + presentedSheet = route + case .fullscreen: + presentedFullScreen = route + } + #else + switch style { + case .push: + path.append(route) + case .sheet: + presentedSheet = route + case .fullscreen: + withAnimation { + presentedFullScreen = route + } + } + #endif + } +} diff --git a/Shared/Coordinators/Navigation/NavigationInjectionView.swift b/Shared/Coordinators/Navigation/NavigationInjectionView.swift new file mode 100644 index 00000000..0f5b1566 --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationInjectionView.swift @@ -0,0 +1,106 @@ +// +// 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 PreferencesView +import SwiftUI +import Transmission + +// TODO: have full screen zoom presentation zoom from/to center +// - probably need to make mock view with matching ids +// TODO: have presentation dismissal be through preference keys +// - issue with all of the VC/view wrapping + +extension EnvironmentValues { + + @Entry + var presentationControllerShouldDismiss: Binding = .constant(true) +} + +struct NavigationInjectionView: View { + + @StateObject + private var coordinator: NavigationCoordinator + @EnvironmentObject + private var rootCoordinator: RootCoordinator + + @State + private var isPresentationInteractive: Bool = true + + private let content: AnyView + + init( + coordinator: @autoclosure @escaping () -> NavigationCoordinator, + @ViewBuilder content: @escaping () -> some View + ) { + _coordinator = StateObject(wrappedValue: coordinator()) + self.content = AnyView(content()) + } + + var body: some View { + NavigationStack(path: $coordinator.path) { + content + .navigationDestination(for: NavigationRoute.self) { route in + route.destination + } + } + .environment( + \.router, + .init( + navigationCoordinator: coordinator, + rootCoordinator: rootCoordinator + ) + ) + .sheet( + item: $coordinator.presentedSheet + ) { + coordinator.presentedSheet = nil + } content: { route in + let newCoordinator = NavigationCoordinator() + + NavigationInjectionView(coordinator: newCoordinator) { + route.destination + } + } + #if os(tvOS) + .fullScreenCover( + item: $coordinator.presentedFullScreen + ) { route in + let newCoordinator = NavigationCoordinator() + + NavigationInjectionView(coordinator: newCoordinator) { + route.destination + } + } + #else + .presentation( + $coordinator.presentedFullScreen, + transition: .zoomIfAvailable( + options: .init( + dimmingVisualEffect: .systemThickMaterialDark, + options: .init( + isInteractive: isPresentationInteractive + ) + ), + otherwise: .slide(.init(edge: .bottom), options: .init(isInteractive: isPresentationInteractive)) + ) + ) { routeBinding, _ in + let vc = UIPreferencesHostingController { + NavigationInjectionView(coordinator: .init()) { + routeBinding.wrappedValue.destination + .environment(\.presentationControllerShouldDismiss, $isPresentationInteractive) + } + } + + // TODO: presentation options for customizing background color, dimming effect, etc. + vc.view.backgroundColor = .black + + return vc + } + #endif + } +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift new file mode 100644 index 00000000..d2c5dd5c --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift @@ -0,0 +1,226 @@ +// +// 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 + +#if os(iOS) +extension NavigationRoute { + + // MARK: - Active Sessions + + static func activeDeviceDetails(box: BindingBox) -> NavigationRoute { + NavigationRoute(id: "activeDeviceDetails") { + ActiveSessionDetailView(box: box) + } + } + + static let activeSessions = NavigationRoute( + id: "activeSessions" + ) { + ActiveSessionsView() + } + + // MARK: - User Activity + + static let activity = NavigationRoute( + id: "activity" + ) { + ServerActivityView() + } + + static func activityDetails(viewModel: ServerActivityDetailViewModel) -> NavigationRoute { + NavigationRoute(id: "activityDetails") { + ServerActivityDetailsView(viewModel: viewModel) + } + } + + static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute { + NavigationRoute( + id: "activityFilters", + style: .sheet + ) { + ServerActivityFilterView(viewModel: viewModel) + } + } + + // MARK: - Server Tasks + + static func addServerTaskTrigger(observer: ServerTaskObserver) -> NavigationRoute { + NavigationRoute( + id: "addServerTaskTrigger", + style: .sheet + ) { + AddTaskTriggerView(observer: observer) + } + } + + // MARK: - Users + + static func addServerUser() -> NavigationRoute { + NavigationRoute( + id: "addServerUser", + style: .sheet + ) { + AddServerUserView() + } + } + + // MARK: - API Keys + + static let apiKeys = NavigationRoute( + id: "apiKeys" + ) { + APIKeysView() + } + + // MARK: - Devices + + static func deviceDetails(device: DeviceInfoDto, viewModel: DevicesViewModel) -> NavigationRoute { + NavigationRoute(id: "deviceDetails") { + DeviceDetailsView(device: device, viewModel: viewModel) + } + } + + static let devices = NavigationRoute( + id: "devices" + ) { + DevicesView() + } + + // MARK: - Server Tasks + + static func editServerTask(observer: ServerTaskObserver) -> NavigationRoute { + NavigationRoute(id: "editServerTask") { + EditServerTaskView(observer: observer) + } + } + + // MARK: - Users + + static func quickConnectAuthorize(user: UserDto) -> NavigationRoute { + NavigationRoute(id: "quickConnectAuthorize") { + QuickConnectAuthorizeView(user: user) + } + } + + static func resetUserPasswordAdmin(userID: String) -> NavigationRoute { + NavigationRoute( + id: "resetUserPasswordAdmin", + style: .sheet + ) { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: false) + } + } + + // MARK: - Server Logs + + static let serverLogs = NavigationRoute( + id: "serverLogs" + ) { + ServerLogsView() + } + + // MARK: - Server Tasks + + static let tasks = NavigationRoute( + id: "tasks" + ) { + ServerTasksView() + } + + // MARK: - Users + + static func userAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userAddAccessSchedule", + style: .sheet + ) { + AddAccessScheduleView(viewModel: viewModel) + } + } + + static func userAddAccessTag(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userAddAccessTag", + style: .sheet + ) { + AddServerUserAccessTagsView(viewModel: viewModel) + } + } + + static func userDetails(user: UserDto) -> NavigationRoute { + NavigationRoute(id: "userDetails") { + ServerUserDetailsView(user: user) + } + } + + static func userDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userDeviceAccess", + style: .sheet + ) { + ServerUserDeviceAccessView(viewModel: viewModel) + } + } + + static func userEditAccessSchedules(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute(id: "userEditAccessSchedules") { + EditAccessScheduleView(viewModel: viewModel) + } + } + + static func userEditAccessTags(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute(id: "userEditAccessTags") { + EditServerUserAccessTagsView(viewModel: viewModel) + } + } + + static func userLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userLiveTVAccess", + style: .sheet + ) { + ServerUserLiveTVAccessView(viewModel: viewModel) + } + } + + static func userMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userMediaAccess", + style: .sheet + ) { + ServerUserMediaAccessView(viewModel: viewModel) + } + } + + static func userParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userParentalRatings", + style: .sheet + ) { + ServerUserParentalRatingView(viewModel: viewModel) + } + } + + static func userPermissions(viewModel: ServerUserAdminViewModel) -> NavigationRoute { + NavigationRoute( + id: "userPermissions", + style: .sheet + ) { + ServerUserPermissionsView(viewModel: viewModel) + } + } + + static let users = NavigationRoute( + id: "users" + ) { + ServerUsersView() + } +} +#endif diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+App.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+App.swift new file mode 100644 index 00000000..39b7a858 --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+App.swift @@ -0,0 +1,50 @@ +// +// 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 NavigationRoute { + + #if os(iOS) + static let aboutApp = NavigationRoute( + id: "about-app" + ) { + AboutAppView() + } + + static func appIconSelector(viewModel: SettingsViewModel) -> NavigationRoute { + NavigationRoute( + id: "app-icon-selector" + ) { + AppIconSelectorView(viewModel: viewModel) + } + } + #endif + + static let appSettings = NavigationRoute( + id: "app-settings", + style: .sheet + ) { + AppSettingsView() + } + + #if os(tvOS) + static let hourPicker = NavigationRoute( + id: "hour-picker", + style: .fullscreen + ) { + ZStack { + BlurView() + .ignoresSafeArea() + + HourMinutePicker() + } + } + #endif +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift new file mode 100644 index 00000000..fa996ea7 --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift @@ -0,0 +1,34 @@ +// +// 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 NavigationRoute { + + static let downloadList = NavigationRoute( + id: "downloadList" + ) { + #if os(iOS) + DownloadListView(viewModel: .init()) + #else + EmptyView() + #endif + } + + #if os(iOS) + static func downloadTask(downloadTask: DownloadTask) -> NavigationRoute { + NavigationRoute( + id: "downloadTask", + style: .sheet + ) { + DownloadTaskView(downloadTask: downloadTask) + } + } + #endif +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift new file mode 100644 index 00000000..88a8963c --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift @@ -0,0 +1,275 @@ +// +// 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 NavigationRoute { + + // MARK: - Item Editing + + #if os(iOS) + static func addGenre(viewModel: GenreEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "addGenre", + style: .sheet + ) { + AddItemElementView(viewModel: viewModel, type: .genres) + } + } + + static func addItemImage(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute { + NavigationRoute( + id: "addItemImage", + style: .push(.automatic) + ) { + AddItemImageView( + viewModel: viewModel, + imageType: imageType + ) + } + } + + static func addPeople(viewModel: PeopleEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "addPeople", + style: .sheet + ) { + AddItemElementView(viewModel: viewModel, type: .people) + } + } + + static func addStudio(viewModel: StudioEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "addStudio", + style: .sheet + ) { + AddItemElementView(viewModel: viewModel, type: .studios) + } + } + + static func addTag(viewModel: TagEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "addTag", + style: .sheet + ) { + AddItemElementView(viewModel: viewModel, type: .tags) + } + } + #endif + + static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute { + let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)" + let viewModel = PagingLibraryViewModel( + title: L10n.castAndCrew, + id: id, + people + ) + + return NavigationRoute(id: "castAndCrew") { + PagingLibraryView(viewModel: viewModel) + } + } + + #if os(iOS) + static func cropItemImage(viewModel: ItemImagesViewModel, image: UIImage, type: ImageType) -> NavigationRoute { + NavigationRoute( + id: "crop-Image" + ) { + ItemPhotoCropView( + viewModel: viewModel, + image: image, + type: type + ) + } + } + + static func editGenres(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "editGenres") { + EditItemElementView( + viewModel: GenreEditorViewModel(item: item), + type: .genres, + route: { router, viewModel in + router.route(to: .addGenre(viewModel: viewModel as! GenreEditorViewModel)) + } + ) + } + } + + static func editSubtitles(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "editSubtitles") { + ItemSubtitlesView(item: item) + } + } + + static func uploadSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "uploadSubtitle", + style: .sheet + ) { + ItemSubtitleUploadView(viewModel: viewModel) + } + } + + static func editMetadata(item: BaseItemDto) -> NavigationRoute { + NavigationRoute( + id: "editMetadata", + style: .sheet + ) { + EditMetadataView(viewModel: ItemEditorViewModel(item: item)) + } + } + + static func editPeople(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "editPeople") { + EditItemElementView( + viewModel: PeopleEditorViewModel(item: item), + type: .people, + route: { router, viewModel in + router.route(to: .addPeople(viewModel: viewModel as! PeopleEditorViewModel)) + } + ) + } + } + + static func editStudios(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "editStudios") { + EditItemElementView( + viewModel: StudioEditorViewModel(item: item), + type: .studios, + route: { router, viewModel in + router.route(to: .addStudio(viewModel: viewModel as! StudioEditorViewModel)) + } + ) + } + } + + static func editTags(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "editTags") { + EditItemElementView( + viewModel: TagEditorViewModel(item: item), + type: .tags, + route: { router, viewModel in + router.route(to: .addTag(viewModel: viewModel as! TagEditorViewModel)) + } + ) + } + } + + static func identifyItem(item: BaseItemDto) -> NavigationRoute { + NavigationRoute(id: "identifyItem") { + IdentifyItemView(item: item) + } + } + + static func identifyItemResults( + viewModel: IdentifyItemViewModel, + result: RemoteSearchResult + ) -> NavigationRoute { + NavigationRoute( + id: "identifyItemResults", + style: .sheet + ) { + IdentifyItemView.RemoteSearchResultView( + viewModel: viewModel, + result: result + ) + } + } + #endif + + static func searchSubtitle(viewModel: SubtitleEditorViewModel) -> NavigationRoute { + NavigationRoute( + id: "searchSubtitle", + style: .sheet + ) { + ItemSubtitleSearchView(viewModel: viewModel) + } + } + + static func item(item: BaseItemDto) -> NavigationRoute { + NavigationRoute( + id: "item-\(item.id ?? "Unknown")", + withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } + ) { + ItemView(item: item) + } + } + + #if os(iOS) + static func itemEditor(viewModel: ItemViewModel) -> NavigationRoute { + NavigationRoute( + id: "itemEditor", + style: .sheet + ) { + ItemEditorView(viewModel: viewModel) + } + } + + static func itemImageDetails(viewModel: ItemImagesViewModel, imageInfo: ImageInfo) -> NavigationRoute { + NavigationRoute( + id: "itemImageDetails", + style: .sheet + ) { + ItemImageDetailsView( + viewModel: viewModel, + imageInfo: imageInfo + ) + .isEditing(true) + } + } + + static func itemImages(viewModel: ItemImagesViewModel) -> NavigationRoute { + NavigationRoute( + id: "itemImages", + style: .sheet + ) { + ItemImagesView(viewModel: viewModel) + } + } + + static func itemImageSelector(viewModel: ItemImagesViewModel, imageType: ImageType) -> NavigationRoute { + NavigationRoute( + id: "itemImageSelector", + style: .sheet + ) { + ItemImagePicker( + viewModel: viewModel, + type: imageType + ) + } + } + + #endif + + static func itemOverview(item: BaseItemDto) -> NavigationRoute { + NavigationRoute( + id: "itemOverview", + style: .sheet + ) { + ItemOverviewView(item: item) + } + } + + #if os(iOS) + + static func itemSearchImageDetails(viewModel: ItemImagesViewModel, remoteImageInfo: RemoteImageInfo) -> NavigationRoute { + NavigationRoute( + id: "itemSearchImageDetails", + style: .sheet + ) { + ItemImageDetailsView( + viewModel: viewModel, + remoteImageInfo: remoteImageInfo + ) + .isEditing(false) + } + } + + #endif +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift new file mode 100644 index 00000000..4dc3142c --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift @@ -0,0 +1,35 @@ +// +// 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 NavigationRoute { + + #if os(iOS) + static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute { + NavigationRoute( + id: "filter", + style: .sheet + ) { + FilterView(viewModel: viewModel, type: type) + } + } + #endif + + static func library( + viewModel: PagingLibraryViewModel + ) -> NavigationRoute { + NavigationRoute( + id: "library-(\(viewModel.parent?.id ?? "Unparented"))", + withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } + ) { + PagingLibraryView(viewModel: viewModel) + } + } +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift new file mode 100644 index 00000000..7f95f77a --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift @@ -0,0 +1,121 @@ +// +// 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 PreferencesView +import SwiftUI +import Transmission + +extension NavigationRoute { + + static let channels = NavigationRoute( + id: "channels" + ) { + ChannelLibraryView() + } + + static let liveTV = NavigationRoute( + id: "liveTV" + ) { + ProgramsView() + } + + static func mediaSourceInfo(source: MediaSourceInfo) -> NavigationRoute { + NavigationRoute( + id: "mediaSourceInfo", + style: .sheet + ) { + MediaSourceInfoView(source: source) + } + } + + #if os(iOS) + static func mediaStreamInfo(mediaStream: MediaStream) -> NavigationRoute { + NavigationRoute(id: "mediaStreamInfo") { + MediaStreamInfoView(mediaStream: mediaStream) + } + } + #endif + + @MainActor + static func videoPlayer( + item: BaseItemDto, + mediaSource: MediaSourceInfo? = nil, + queue: (any MediaPlayerQueue)? = nil + ) -> NavigationRoute { + let provider = MediaPlayerItemProvider(item: item) { item in + try await MediaPlayerItem.build(for: item, mediaSource: mediaSource) + } + return Self.videoPlayer(provider: provider, queue: queue) + } + + @MainActor + static func videoPlayer( + provider: MediaPlayerItemProvider, + queue: (any MediaPlayerQueue)? = nil + ) -> NavigationRoute { + let manager = MediaPlayerManager( + item: provider.item, + queue: queue, + mediaPlayerItemProvider: provider.function + ) + + return Self.videoPlayer(manager: manager) + } + + @MainActor + static func videoPlayer(manager: MediaPlayerManager) -> NavigationRoute { + + Container.shared.mediaPlayerManager.register { + manager + } + + Container.shared.mediaPlayerManagerPublisher() + .send(manager) + + return NavigationRoute( + id: "videoPlayer", + style: .fullscreen + ) { + VideoPlayerViewShim(manager: manager) + } + } +} + +// TODO: shim until native vs swiftfin player is replace with vlc vs av layers +// - when removed, ensure same behavior with safe area +// - may just need to make a VC wrapper to capture them + +struct VideoPlayerViewShim: View { + + @State + private var safeAreaInsets: EdgeInsets = .init() + + let manager: MediaPlayerManager + + var body: some View { + Group { + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + VideoPlayer() + } else { + NativeVideoPlayer() + } + } + .colorScheme(.dark) // use over `preferredColorScheme(.dark)` to not have destination change + .environment(\.safeAreaInsets, safeAreaInsets) + .supportedOrientations(.allButUpsideDown) + .ignoresSafeArea() + .persistentSystemOverlays(.hidden) + .toolbar(.hidden, for: .navigationBar) + .onSizeChanged { _, safeArea in + self.safeAreaInsets = safeArea.max(EdgeInsets.edgePadding) + } + } +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift new file mode 100644 index 00000000..c1178b53 --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift @@ -0,0 +1,205 @@ +// +// 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 PulseUI +import SwiftUI + +extension NavigationRoute { + + #if os(iOS) + static func actionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> NavigationRoute { + NavigationRoute(id: "actionButtonSelector") { + ActionButtonSelectorView(selection: selectedButtonsBinding) + } + } + + static let adminDashboard = NavigationRoute( + id: "adminDashboard" + ) { + AdminDashboardView() + } + #endif + + static let createCustomDeviceProfile = NavigationRoute( + id: "createCustomDeviceProfile", + style: .sheet + ) { + CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: nil) + .navigationTitle(L10n.customProfile) + } + + static let customDeviceProfileSettings = NavigationRoute( + id: "customDeviceProfileSettings" + ) { + CustomDeviceProfileSettingsView() + } + + static let customizeViewsSettings = NavigationRoute( + id: "customizeViewsSettings" + ) { + CustomizeViewsSettings() + } + + #if DEBUG && !os(tvOS) + static let debugSettings = NavigationRoute( + id: "debugSettings" + ) { + DebugSettingsView() + } + #endif + + static func editCustomDeviceProfile(profile: Binding) -> NavigationRoute { + NavigationRoute( + id: "editCustomDeviceProfile", + style: .sheet + ) { + CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: profile) + .navigationTitle(L10n.customProfile) + } + } + + static func editCustomDeviceProfileAudio(selection: Binding<[AudioCodec]>) -> NavigationRoute { + NavigationRoute(id: "editCustomDeviceProfileAudio") { + OrderedSectionSelectorView(selection: selection, sources: AudioCodec.allCases) + .navigationTitle(L10n.audio) + } + } + + static func editCustomDeviceProfileContainer(selection: Binding<[MediaContainer]>) -> NavigationRoute { + NavigationRoute(id: "editCustomDeviceProfileContainer") { + OrderedSectionSelectorView(selection: selection, sources: MediaContainer.allCases) + .navigationTitle(L10n.containers) + } + } + + static func editCustomDeviceProfileVideo(selection: Binding<[VideoCodec]>) -> NavigationRoute { + NavigationRoute(id: "editCustomDeviceProfileVideo") { + OrderedSectionSelectorView(selection: selection, sources: VideoCodec.allCases) + .navigationTitle(L10n.video) + } + } + + static func editServer(server: ServerState, isEditing: Bool = false) -> NavigationRoute { + NavigationRoute(id: "editServer") { + EditServerView(server: server) + .isEditing(isEditing) + } + } + + static let experimentalSettings = NavigationRoute( + id: "experimentalSettings" + ) { + ExperimentalSettingsView() + } + + static func fontPicker(selection: Binding) -> NavigationRoute { + NavigationRoute(id: "fontPicker") { + FontPickerView(selection: selection) + } + } + + #if os(iOS) + static let gestureSettings = NavigationRoute( + id: "gestureSettings" + ) { + GestureSettingsView() + } + #endif + + static let indicatorSettings = NavigationRoute( + id: "indicatorSettings" + ) { + IndicatorSettingsView() + } + + static func itemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> NavigationRoute { + NavigationRoute(id: "itemFilterDrawerSelector") { + OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) + .navigationTitle(L10n.filters) + } + } + + static func itemOverviewView(item: BaseItemDto) -> NavigationRoute { + NavigationRoute( + id: "itemOverviewView", + style: .sheet + ) { + ItemOverviewView(item: item) + } + } + + static func itemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> NavigationRoute { + NavigationRoute(id: "itemViewAttributes") { + OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases) + .navigationTitle(L10n.mediaAttributes.localizedCapitalized) + } + } + + static let localSecurity = NavigationRoute( + id: "localSecurity" + ) { + UserLocalSecurityView() + } + + static let log = NavigationRoute( + id: "log" + ) { + ConsoleView() + } + + #if os(iOS) + static let nativePlayerSettings = NavigationRoute( + id: "nativePlayerSettings" + ) { + NativeVideoPlayerSettingsView() + } + #endif + + static let playbackQualitySettings = NavigationRoute( + id: "playbackQualitySettings" + ) { + PlaybackQualitySettingsView() + } + + #if os(iOS) + static func resetUserPassword(userID: String) -> NavigationRoute { + NavigationRoute( + id: "resetUserPassword", + style: .sheet + ) { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: true) + } + } + #endif + + static func serverConnection(server: ServerState) -> NavigationRoute { + NavigationRoute(id: "serverConnection") { + EditServerView(server: server) + } + } + + static let settings = NavigationRoute( + id: "settings", + style: .sheet + ) { + SettingsView() + } + + static func userProfile(viewModel: SettingsViewModel) -> NavigationRoute { + NavigationRoute(id: "userProfile") { + UserProfileSettingsView(viewModel: viewModel) + } + } + + static let videoPlayerSettings = NavigationRoute( + id: "videoPlayerSettings" + ) { + VideoPlayerSettingsView() + } +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+User.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+User.swift new file mode 100644 index 00000000..9b177779 --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+User.swift @@ -0,0 +1,78 @@ +// +// 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 NavigationRoute { + + static let connectToServer = NavigationRoute( + id: "connectToServer", + style: .sheet + ) { + ConnectToServerView() + } + + static func quickConnect(quickConnect: QuickConnect) -> NavigationRoute { + NavigationRoute( + id: "quickConnectView", + style: .sheet + ) { + QuickConnectView(quickConnect: quickConnect) + } + } + + #if os(iOS) + static func userProfileImage(viewModel: UserProfileImageViewModel) -> NavigationRoute { + NavigationRoute( + id: "userProfileImage", + style: .sheet + ) { + UserProfileImagePickerView(viewModel: viewModel) + } + } + + static func userProfileImageCrop(viewModel: UserProfileImageViewModel, image: UIImage) -> NavigationRoute { + NavigationRoute( + id: "cropImage", + style: .sheet + ) { + UserProfileImageCropView( + viewModel: viewModel, + image: image + ) + } + } + + // TODO: rename to `localUserAccessPolicy` + static func userSecurity(pinHint: Binding, accessPolicy: Binding) -> NavigationRoute { + NavigationRoute( + id: "userSecurity", + style: .sheet + ) { + LocalUserAccessPolicyView( + pinHint: pinHint, + accessPolicy: accessPolicy + ) + } + } + #endif + + static func userSignIn(server: ServerState) -> NavigationRoute { + NavigationRoute( + id: "userSignIn", + style: .sheet + ) { + WithUserAuthentication { + WithQuickConnect { + UserSignInView(server: server) + } + } + } + } +} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute.swift new file mode 100644 index 00000000..511cdf4e --- /dev/null +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute.swift @@ -0,0 +1,87 @@ +// +// 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 Foundation +import SwiftUI + +struct NavigationRoute: Identifiable, Hashable { + + enum TransitionStyle: Hashable { + + // TODO: sheet and fullscreen with `NavigationTransition` + case push(NavigationTransition) + case sheet + case fullscreen + } + + enum TransitionType { + + case automatic(TransitionStyle) + case withNamespace((Namespace.ID) -> TransitionStyle) + } + + let id: String + + private let content: AnyView + var transitionType: TransitionType + var namespace: Namespace.ID? + + var transitionStyle: TransitionStyle { + switch transitionType { + case let .automatic(style): + return style + case let .withNamespace(builder): + if let namespace { + return builder(namespace) + } else { + return .push(.automatic) + } + } + } + + init( + id: String, + style: TransitionStyle = .push(.automatic), + @ViewBuilder content: () -> some View + ) { + self.id = id + self.transitionType = .automatic(style) + self.namespace = nil + self.content = AnyView(content()) + } + + init( + id: String, + withNamespace: @escaping (Namespace.ID) -> TransitionStyle, + @ViewBuilder content: () -> some View + ) { + self.id = id + self.transitionType = .withNamespace(withNamespace) + self.namespace = nil + self.content = AnyView(content()) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + @ViewBuilder + var destination: some View { + if case let .push(style) = transitionStyle { + content + .backport + .navigationTransition(style) + } else { + content + } + } +} diff --git a/Shared/Coordinators/Navigation/Router.swift b/Shared/Coordinators/Navigation/Router.swift new file mode 100644 index 00000000..3642ab13 --- /dev/null +++ b/Shared/Coordinators/Navigation/Router.swift @@ -0,0 +1,101 @@ +// +// 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 NavigationCoordinator { + + @MainActor + struct Router { + + let navigationCoordinator: NavigationCoordinator? + let rootCoordinator: RootCoordinator? + + func route( + to route: NavigationRoute, + transition: NavigationRoute.TransitionType? = nil, + in namespace: Namespace.ID? = nil + ) { + var route = route + route.namespace = namespace + route.transitionType = transition ?? route.transitionType + navigationCoordinator?.push(route) + } + + func root( + _ root: RootItem + ) { + rootCoordinator?.root(root) + } + } +} + +@propertyWrapper +struct Router: DynamicProperty { + + @MainActor + struct Wrapper { + let router: NavigationCoordinator.Router + let dismiss: DismissAction + + func route( + to route: NavigationRoute, + in namespace: Namespace.ID? = nil + ) { + router.route( + to: route, + transition: nil, + in: namespace + ) + } + + func route( + to route: NavigationRoute, + style: NavigationRoute.TransitionStyle, + in namespace: Namespace.ID? = nil + ) { + router.route( + to: route, + transition: .automatic(style), + in: namespace + ) + } + + func route( + to route: NavigationRoute, + withNamespace: @escaping (Namespace.ID) -> NavigationRoute.TransitionStyle, + in namespace: Namespace.ID? = nil + ) { + router.route( + to: route, + transition: .withNamespace(withNamespace), + in: namespace + ) + } + } + + // `.dismiss` causes changes on disappear + @Environment(\.self) + private var environment + + var wrappedValue: Wrapper { + .init( + router: environment.router, + dismiss: environment.dismiss + ) + } +} + +extension EnvironmentValues { + + @Entry + var router: NavigationCoordinator.Router = .init( + navigationCoordinator: nil, + rootCoordinator: nil + ) +} diff --git a/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift b/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift new file mode 100644 index 00000000..6fef4d83 --- /dev/null +++ b/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift @@ -0,0 +1,56 @@ +// +// 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 +// + +#if os(iOS) +import SwiftUI +import Transmission + +// TODO: sometimes causes hangs? + +struct WithTransitionReaderPublisher: View { + + @StateObject + private var publishedBox: PublishedBox> = .init(initialValue: .init()) + + let content: Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content() + } + + var body: some View { + content + .environment(\.transitionReader, publishedBox.value) + .background { + TransitionReader { proxy in + Color.clear + .onChange(of: proxy) { newValue in + publishedBox.value.send(newValue) + } + } + } + } +} + +@propertyWrapper +struct TransitionReaderObserver: DynamicProperty { + + @Environment(\.transitionReader) + private var publisher + + var wrappedValue: LegacyEventPublisher { + publisher + } +} + +extension EnvironmentValues { + + @Entry + var transitionReader: LegacyEventPublisher = .init() +} +#endif diff --git a/Shared/Coordinators/Root/RootCoordinator.swift b/Shared/Coordinators/Root/RootCoordinator.swift new file mode 100644 index 00000000..b7ba6eb5 --- /dev/null +++ b/Shared/Coordinators/Root/RootCoordinator.swift @@ -0,0 +1,86 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import Logging +import SwiftUI + +@MainActor +final class RootCoordinator: ObservableObject { + + @Published + var root: RootItem = .appLoading + + private let logger = Logger.swiftfin() + + init() { + Task { + do { + try await SwiftfinStore.setupDataStack() + + if Container.shared.currentUserSession() != nil, !Defaults[.signOutOnClose] { + #if os(tvOS) + await MainActor.run { + root(.mainTab) + } + #else + await MainActor.run { + root(.serverCheck) + } + #endif + } else { + await MainActor.run { + root(.selectUser) + } + } + + } catch { + await MainActor.run { + Notifications[.didFailMigration].post() + } + } + } + + // Notification setup for state + Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) + Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) + Notifications[.didChangeCurrentServerURL].subscribe(self, selector: #selector(didChangeCurrentServerURL(_:))) + } + + func root(_ newRoot: RootItem) { + root = newRoot + } + + @objc + private func didSignIn() { + logger.info("Signed in") + + #if os(tvOS) + root(.mainTab) + #else + root(.serverCheck) + #endif + } + + @objc + private func didSignOut() { + logger.info("Signed out") + + root(.selectUser) + } + + @objc + func didChangeCurrentServerURL(_ notification: Notification) { + + guard Container.shared.currentUserSession() != nil else { return } + + Container.shared.currentUserSession.reset() + Notifications[.didSignIn].post() + } +} diff --git a/Shared/Coordinators/Root/RootItem.swift b/Shared/Coordinators/Root/RootItem.swift new file mode 100644 index 00000000..11939a38 --- /dev/null +++ b/Shared/Coordinators/Root/RootItem.swift @@ -0,0 +1,48 @@ +// +// 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 + +@MainActor +struct RootItem: Identifiable { + + var id: String + let content: AnyView + + init( + id: String, + @ViewBuilder content: () -> some View + ) { + self.id = id + self.content = AnyView(content()) + } + + static let appLoading = RootItem(id: "appLoading") { + NavigationInjectionView(coordinator: .init()) { + AppLoadingView() + } + } + + static let mainTab = RootItem(id: "mainTab") { + MainTabView() + } + + static let selectUser = RootItem(id: "selectUser") { + NavigationInjectionView(coordinator: .init()) { + SelectUserView() + } + } + + #if os(iOS) + static let serverCheck = RootItem(id: "serverCheck") { + NavigationInjectionView(coordinator: .init()) { + ServerCheckView() + } + } + #endif +} diff --git a/Shared/Coordinators/Root/RootView.swift b/Shared/Coordinators/Root/RootView.swift new file mode 100644 index 00000000..b1434b06 --- /dev/null +++ b/Shared/Coordinators/Root/RootView.swift @@ -0,0 +1,48 @@ +// +// 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 +import Transmission + +// Status bar presentation needs to happen at this level +struct RootView: View { + + @State + private var isStatusBarHidden: Bool = false + + @StateObject + private var rootCoordinator: RootCoordinator = .init() + + var body: some View { + ZStack { + if rootCoordinator.root.id == RootItem.appLoading.id { + RootItem.appLoading.content + } + + if rootCoordinator.root.id == RootItem.mainTab.id { + RootItem.mainTab.content + } + + if rootCoordinator.root.id == RootItem.selectUser.id { + RootItem.selectUser.content + } + + #if os(iOS) + if rootCoordinator.root.id == RootItem.serverCheck.id { + RootItem.serverCheck.content + } + #endif + } + .animation(.linear(duration: 0.1), value: rootCoordinator.root.id) + .environmentObject(rootCoordinator) + .prefersStatusBarHidden(isStatusBarHidden) + .onPreferenceChange(IsStatusBarHiddenKey.self) { newValue in + isStatusBarHidden = newValue + } + } +} diff --git a/Shared/Coordinators/SearchCoordinator 2.swift b/Shared/Coordinators/SearchCoordinator 2.swift new file mode 100644 index 00000000..3746781f --- /dev/null +++ b/Shared/Coordinators/SearchCoordinator 2.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class SearchCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \SearchCoordinator.start) + + @Root + var start = makeStart + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + @Route(.modal) + var filter = makeFilter + #endif + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + #if !os(tvOS) + func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) + } + #endif + + @ViewBuilder + func makeStart() -> some View { + SearchView() + } +} diff --git a/Shared/Coordinators/SearchCoordinator 3.swift b/Shared/Coordinators/SearchCoordinator 3.swift new file mode 100644 index 00000000..3746781f --- /dev/null +++ b/Shared/Coordinators/SearchCoordinator 3.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class SearchCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \SearchCoordinator.start) + + @Root + var start = makeStart + #if os(tvOS) + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + @Route(.modal) + var filter = makeFilter + #endif + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { + LibraryCoordinator(viewModel: viewModel) + } + + #if !os(tvOS) + func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator(parameters: parameters)) + } + #endif + + @ViewBuilder + func makeStart() -> some View { + SearchView() + } +} diff --git a/Shared/Coordinators/SelectUserCoordinator 2.swift b/Shared/Coordinators/SelectUserCoordinator 2.swift new file mode 100644 index 00000000..cf3ab555 --- /dev/null +++ b/Shared/Coordinators/SelectUserCoordinator 2.swift @@ -0,0 +1,59 @@ +// +// 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 Foundation +import Stinsen +import SwiftUI + +final class SelectUserCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \SelectUserCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var advancedSettings = makeAdvancedSettings + @Route(.push) + var connectToServer = makeConnectToServer + @Route(.push) + var editServer = makeEditServer + @Route(.push) + var userSignIn = makeUserSignIn + + func makeAdvancedSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(AppSettingsCoordinator()) + } + + func makeConnectToServer() -> NavigationViewCoordinator { + NavigationViewCoordinator { + ConnectToServerView() + } + } + + func makeEditServer(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator { + EditServerView(server: server) + .environment(\.isEditing, true) + #if os(iOS) + .navigationBarCloseButton { + self.popLast() + } + #endif + } + } + + func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserSignInCoordinator(server: server)) + } + + @ViewBuilder + func makeStart() -> some View { + SelectUserView() + } +} diff --git a/Shared/Coordinators/SelectUserCoordinator 3.swift b/Shared/Coordinators/SelectUserCoordinator 3.swift new file mode 100644 index 00000000..cf3ab555 --- /dev/null +++ b/Shared/Coordinators/SelectUserCoordinator 3.swift @@ -0,0 +1,59 @@ +// +// 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 Foundation +import Stinsen +import SwiftUI + +final class SelectUserCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \SelectUserCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var advancedSettings = makeAdvancedSettings + @Route(.push) + var connectToServer = makeConnectToServer + @Route(.push) + var editServer = makeEditServer + @Route(.push) + var userSignIn = makeUserSignIn + + func makeAdvancedSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(AppSettingsCoordinator()) + } + + func makeConnectToServer() -> NavigationViewCoordinator { + NavigationViewCoordinator { + ConnectToServerView() + } + } + + func makeEditServer(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator { + EditServerView(server: server) + .environment(\.isEditing, true) + #if os(iOS) + .navigationBarCloseButton { + self.popLast() + } + #endif + } + } + + func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserSignInCoordinator(server: server)) + } + + @ViewBuilder + func makeStart() -> some View { + SelectUserView() + } +} diff --git a/Shared/Coordinators/SelectUserCoordinator.swift b/Shared/Coordinators/SelectUserCoordinator.swift index cf3ab555..79ce7a5a 100644 --- a/Shared/Coordinators/SelectUserCoordinator.swift +++ b/Shared/Coordinators/SelectUserCoordinator.swift @@ -22,6 +22,10 @@ final class SelectUserCoordinator: NavigationCoordinatable { @Route(.push) var connectToServer = makeConnectToServer @Route(.push) + var connectToXtream = makeConnectToXtream + @Route(.push) + var dualServerConnect = makeDualServerConnect + @Route(.push) var editServer = makeEditServer @Route(.push) var userSignIn = makeUserSignIn @@ -30,10 +34,19 @@ final class SelectUserCoordinator: NavigationCoordinatable { NavigationViewCoordinator(AppSettingsCoordinator()) } - func makeConnectToServer() -> NavigationViewCoordinator { - NavigationViewCoordinator { - ConnectToServerView() - } + @ViewBuilder + func makeConnectToServer() -> some View { + ConnectToServerView() + } + + @ViewBuilder + func makeConnectToXtream() -> some View { + ConnectToXtreamView() + } + + @ViewBuilder + func makeDualServerConnect() -> some View { + DualServerConnectView() } func makeEditServer(server: ServerState) -> NavigationViewCoordinator { diff --git a/Shared/Coordinators/SettingsCoordinator 2.swift b/Shared/Coordinators/SettingsCoordinator 2.swift new file mode 100644 index 00000000..acd67e48 --- /dev/null +++ b/Shared/Coordinators/SettingsCoordinator 2.swift @@ -0,0 +1,263 @@ +// +// 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 PulseUI +import Stinsen +import SwiftUI + +final class SettingsCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \SettingsCoordinator.start) + + @Root + var start = makeStart + + #if os(iOS) + @Route(.push) + var log = makeLog + @Route(.push) + var nativePlayerSettings = makeNativePlayerSettings + @Route(.push) + var playbackQualitySettings = makePlaybackQualitySettings + @Route(.push) + var quickConnect = makeQuickConnectAuthorize + @Route(.push) + var resetUserPassword = makeResetUserPassword + @Route(.push) + var localSecurity = makeLocalSecurity + @Route(.push) + var photoPicker = makePhotoPicker + @Route(.push) + var userProfile = makeUserProfileSettings + + @Route(.push) + var customizeViewsSettings = makeCustomizeViewsSettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings + @Route(.push) + var itemFilterDrawerSelector = makeItemFilterDrawerSelector + @Route(.push) + var indicatorSettings = makeIndicatorSettings + @Route(.push) + var itemViewAttributes = makeItemViewAttributes + @Route(.push) + var serverConnection = makeServerConnection + @Route(.push) + var videoPlayerSettings = makeVideoPlayerSettings + @Route(.push) + var customDeviceProfileSettings = makeCustomDeviceProfileSettings + @Route(.push) + var itemOverviewView = makeItemOverviewView + + @Route(.push) + var editCustomDeviceProfile = makeEditCustomDeviceProfile + @Route(.push) + var createCustomDeviceProfile = makeCreateCustomDeviceProfile + + @Route(.push) + var adminDashboard = makeAdminDashboard + + #if DEBUG + @Route(.push) + var debugSettings = makeDebugSettings + #endif + #endif + + #if os(tvOS) + @Route(.push) + var customizeViewsSettings = makeCustomizeViewsSettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings + @Route(.push) + var log = makeLog + @Route(.push) + var serverDetail = makeServerDetail + @Route(.push) + var videoPlayerSettings = makeVideoPlayerSettings + @Route(.push) + var playbackQualitySettings = makePlaybackQualitySettings + @Route(.push) + var userProfile = makeUserProfileSettings + #endif + + #if os(iOS) + @ViewBuilder + func makeNativePlayerSettings() -> some View { + NativeVideoPlayerSettingsView() + } + + @ViewBuilder + func makePlaybackQualitySettings() -> some View { + PlaybackQualitySettingsView() + } + + @ViewBuilder + func makeCustomDeviceProfileSettings() -> some View { + CustomDeviceProfileSettingsView() + } + + func makeEditCustomDeviceProfile(profile: Binding) + -> NavigationViewCoordinator + { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) + } + + func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator()) + } + + @ViewBuilder + func makeQuickConnectAuthorize(user: UserDto) -> some View { + QuickConnectAuthorizeView(user: user) + } + + func makeResetUserPassword(userID: String) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: true) + } + } + + @ViewBuilder + func makeLocalSecurity() -> some View { + UserLocalSecurityView() + } + + func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel)) + } + + @ViewBuilder + func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View { + UserProfileSettingsView(viewModel: viewModel) + } + + @ViewBuilder + func makeCustomizeViewsSettings() -> some View { + CustomizeViewsSettings() + } + + @ViewBuilder + func makeExperimentalSettings() -> some View { + ExperimentalSettingsView() + } + + @ViewBuilder + func makeIndicatorSettings() -> some View { + IndicatorSettingsView() + } + + @ViewBuilder + func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases) + .navigationTitle(L10n.mediaAttributes.localizedCapitalized) + } + + @ViewBuilder + func makeServerConnection(server: ServerState) -> some View { + EditServerView(server: server) + } + + func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ItemOverviewView(item: item) + } + } + + func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) + .navigationTitle(L10n.filters) + } + + func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator { + VideoPlayerSettingsCoordinator() + } + + @ViewBuilder + func makeAdminDashboard() -> some View { + AdminDashboardCoordinator().view() + } + + #if DEBUG + @ViewBuilder + func makeDebugSettings() -> some View { + DebugSettingsView() + } + #endif + #endif + + #if os(tvOS) + + // MARK: - User Profile View + + func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator( + UserProfileSettingsCoordinator(viewModel: viewModel) + ) + } + + // MARK: - Customize Settings View + + func makeCustomizeViewsSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + CustomizeSettingsCoordinator() + ) + } + + // MARK: - Experimental Settings View + + func makeExperimentalSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + BasicNavigationViewCoordinator { + ExperimentalSettingsView() + } + ) + } + + // MARK: - Poster Indicator Settings View + + func makeIndicatorSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator { + IndicatorSettingsView() + } + } + + // MARK: - Server Settings View + + func makeServerDetail(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator { + EditServerView(server: server) + } + } + + // MARK: - Video Player Settings View + + func makeVideoPlayerSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + VideoPlayerSettingsCoordinator() + ) + } + + // MARK: - Playback Settings View + + func makePlaybackQualitySettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + PlaybackQualitySettingsCoordinator() + ) + } + #endif + + @ViewBuilder + func makeLog() -> some View { + ConsoleView() + } + + @ViewBuilder + func makeStart() -> some View { + SettingsView() + } +} diff --git a/Shared/Coordinators/SettingsCoordinator 3.swift b/Shared/Coordinators/SettingsCoordinator 3.swift new file mode 100644 index 00000000..acd67e48 --- /dev/null +++ b/Shared/Coordinators/SettingsCoordinator 3.swift @@ -0,0 +1,263 @@ +// +// 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 PulseUI +import Stinsen +import SwiftUI + +final class SettingsCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \SettingsCoordinator.start) + + @Root + var start = makeStart + + #if os(iOS) + @Route(.push) + var log = makeLog + @Route(.push) + var nativePlayerSettings = makeNativePlayerSettings + @Route(.push) + var playbackQualitySettings = makePlaybackQualitySettings + @Route(.push) + var quickConnect = makeQuickConnectAuthorize + @Route(.push) + var resetUserPassword = makeResetUserPassword + @Route(.push) + var localSecurity = makeLocalSecurity + @Route(.push) + var photoPicker = makePhotoPicker + @Route(.push) + var userProfile = makeUserProfileSettings + + @Route(.push) + var customizeViewsSettings = makeCustomizeViewsSettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings + @Route(.push) + var itemFilterDrawerSelector = makeItemFilterDrawerSelector + @Route(.push) + var indicatorSettings = makeIndicatorSettings + @Route(.push) + var itemViewAttributes = makeItemViewAttributes + @Route(.push) + var serverConnection = makeServerConnection + @Route(.push) + var videoPlayerSettings = makeVideoPlayerSettings + @Route(.push) + var customDeviceProfileSettings = makeCustomDeviceProfileSettings + @Route(.push) + var itemOverviewView = makeItemOverviewView + + @Route(.push) + var editCustomDeviceProfile = makeEditCustomDeviceProfile + @Route(.push) + var createCustomDeviceProfile = makeCreateCustomDeviceProfile + + @Route(.push) + var adminDashboard = makeAdminDashboard + + #if DEBUG + @Route(.push) + var debugSettings = makeDebugSettings + #endif + #endif + + #if os(tvOS) + @Route(.push) + var customizeViewsSettings = makeCustomizeViewsSettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings + @Route(.push) + var log = makeLog + @Route(.push) + var serverDetail = makeServerDetail + @Route(.push) + var videoPlayerSettings = makeVideoPlayerSettings + @Route(.push) + var playbackQualitySettings = makePlaybackQualitySettings + @Route(.push) + var userProfile = makeUserProfileSettings + #endif + + #if os(iOS) + @ViewBuilder + func makeNativePlayerSettings() -> some View { + NativeVideoPlayerSettingsView() + } + + @ViewBuilder + func makePlaybackQualitySettings() -> some View { + PlaybackQualitySettingsView() + } + + @ViewBuilder + func makeCustomDeviceProfileSettings() -> some View { + CustomDeviceProfileSettingsView() + } + + func makeEditCustomDeviceProfile(profile: Binding) + -> NavigationViewCoordinator + { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) + } + + func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator()) + } + + @ViewBuilder + func makeQuickConnectAuthorize(user: UserDto) -> some View { + QuickConnectAuthorizeView(user: user) + } + + func makeResetUserPassword(userID: String) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ResetUserPasswordView(userID: userID, requiresCurrentPassword: true) + } + } + + @ViewBuilder + func makeLocalSecurity() -> some View { + UserLocalSecurityView() + } + + func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(UserProfileImageCoordinator(viewModel: viewModel)) + } + + @ViewBuilder + func makeUserProfileSettings(viewModel: SettingsViewModel) -> some View { + UserProfileSettingsView(viewModel: viewModel) + } + + @ViewBuilder + func makeCustomizeViewsSettings() -> some View { + CustomizeViewsSettings() + } + + @ViewBuilder + func makeExperimentalSettings() -> some View { + ExperimentalSettingsView() + } + + @ViewBuilder + func makeIndicatorSettings() -> some View { + IndicatorSettingsView() + } + + @ViewBuilder + func makeItemViewAttributes(selection: Binding<[ItemViewAttribute]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemViewAttribute.allCases) + .navigationTitle(L10n.mediaAttributes.localizedCapitalized) + } + + @ViewBuilder + func makeServerConnection(server: ServerState) -> some View { + EditServerView(server: server) + } + + func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ItemOverviewView(item: item) + } + } + + func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) + .navigationTitle(L10n.filters) + } + + func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator { + VideoPlayerSettingsCoordinator() + } + + @ViewBuilder + func makeAdminDashboard() -> some View { + AdminDashboardCoordinator().view() + } + + #if DEBUG + @ViewBuilder + func makeDebugSettings() -> some View { + DebugSettingsView() + } + #endif + #endif + + #if os(tvOS) + + // MARK: - User Profile View + + func makeUserProfileSettings(viewModel: SettingsViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator( + UserProfileSettingsCoordinator(viewModel: viewModel) + ) + } + + // MARK: - Customize Settings View + + func makeCustomizeViewsSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + CustomizeSettingsCoordinator() + ) + } + + // MARK: - Experimental Settings View + + func makeExperimentalSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + BasicNavigationViewCoordinator { + ExperimentalSettingsView() + } + ) + } + + // MARK: - Poster Indicator Settings View + + func makeIndicatorSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator { + IndicatorSettingsView() + } + } + + // MARK: - Server Settings View + + func makeServerDetail(server: ServerState) -> NavigationViewCoordinator { + NavigationViewCoordinator { + EditServerView(server: server) + } + } + + // MARK: - Video Player Settings View + + func makeVideoPlayerSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + VideoPlayerSettingsCoordinator() + ) + } + + // MARK: - Playback Settings View + + func makePlaybackQualitySettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + PlaybackQualitySettingsCoordinator() + ) + } + #endif + + @ViewBuilder + func makeLog() -> some View { + ConsoleView() + } + + @ViewBuilder + func makeStart() -> some View { + SettingsView() + } +} diff --git a/Shared/Coordinators/Tabs/MainTabView.swift b/Shared/Coordinators/Tabs/MainTabView.swift new file mode 100644 index 00000000..78b46250 --- /dev/null +++ b/Shared/Coordinators/Tabs/MainTabView.swift @@ -0,0 +1,69 @@ +// +// 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 SwiftUI + +// TODO: move popup to router +// - or, make tab view environment object + +// TODO: fix weird tvOS icon rendering +struct MainTabView: View { + + #if os(iOS) + @StateObject + private var tabCoordinator = TabCoordinator { + TabItem.home + TabItem.search + TabItem.media + } + #else + @StateObject + private var tabCoordinator = TabCoordinator { + TabItem.home + TabItem.library( + title: L10n.tvShows, + systemName: "tv", + filters: .init(itemTypes: [.series]) + ) + TabItem.library( + title: L10n.movies, + systemName: "film", + filters: .init(itemTypes: [.movie]) + ) + TabItem.search + TabItem.media + TabItem.settings + } + #endif + + @ViewBuilder + var body: some View { + TabView(selection: $tabCoordinator.selectedTabID) { + ForEach(tabCoordinator.tabs, id: \.item.id) { tab in + NavigationInjectionView( + coordinator: tab.coordinator + ) { + tab.item.content + } + .environmentObject(tabCoordinator) + .environment(\.tabItemSelected, tab.publisher) + .tabItem { + Label( + tab.item.title, + systemImage: tab.item.systemImage + ) + .labelStyle(tab.item.labelStyle) + .symbolRenderingMode(.monochrome) + .eraseToAnyView() + } + .tag(tab.item.id) + } + } + } +} diff --git a/Shared/Coordinators/Tabs/TabCoordinator.swift b/Shared/Coordinators/Tabs/TabCoordinator.swift new file mode 100644 index 00000000..ef2038ae --- /dev/null +++ b/Shared/Coordinators/Tabs/TabCoordinator.swift @@ -0,0 +1,50 @@ +// +// 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 + +@MainActor +final class TabCoordinator: ObservableObject { + + struct SelectedEvent { + let isRoot: Bool + let isRepeat: Bool + } + + typealias TabData = ( + item: TabItem, + coordinator: NavigationCoordinator, + publisher: TabItemSelectedPublisher + ) + + @Published + var selectedTabID: String! = nil { + didSet { + guard let tab = tabs.first(property: \.item.id, equalTo: selectedTabID) else { return } + + tab.publisher.send( + .init( + isRoot: tab.coordinator.path.isEmpty, + isRepeat: oldValue == selectedTabID + ) + ) + } + } + + @Published + var tabs: [TabData] = [] + + init(@ArrayBuilder tabs: () -> [TabItem]) { + let tabs = tabs() + self.tabs = tabs.map { tab in + let coordinator = NavigationCoordinator() + let event = TabItemSelectedPublisher() + return (tab, coordinator, event) + } + } +} diff --git a/Shared/Coordinators/Tabs/TabItem.swift b/Shared/Coordinators/Tabs/TabItem.swift new file mode 100644 index 00000000..9ed9e38e --- /dev/null +++ b/Shared/Coordinators/Tabs/TabItem.swift @@ -0,0 +1,95 @@ +// +// 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 + +// TODO: selected icon +struct TabItem: Identifiable, Hashable { + + let content: AnyView + let id: String + let title: String + let systemImage: String + let labelStyle: any LabelStyle + + init( + id: String, + title: String, + systemImage: String, + labelStyle: some LabelStyle = .titleAndIcon, + @ViewBuilder content: () -> some View + ) { + self.content = AnyView(content()) + self.id = id + self.title = title + self.systemImage = systemImage + self.labelStyle = labelStyle + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } +} + +extension TabItem { + + static let home = TabItem( + id: "home", + title: L10n.home, + systemImage: "house" + ) { + HomeView() + } + + static func library( + title: String, + systemName: String, + filters: ItemFilterCollection + ) -> TabItem { + TabItem( + id: "library-\(UUID().uuidString)", + title: title, + systemImage: systemName + ) { + let viewModel = ItemLibraryViewModel( + filters: filters + ) + + PagingLibraryView(viewModel: viewModel) + } + } + + static let media = TabItem( + id: "media", + title: L10n.media, + systemImage: "rectangle.stack.fill" + ) { + MediaView() + } + + static let search = TabItem( + id: "search", + title: L10n.search, + systemImage: "magnifyingglass" + ) { + SearchView() + } + + static let settings = TabItem( + id: "settings", + title: L10n.settings, + systemImage: "gearshape", + labelStyle: .iconOnly + ) { + SettingsView() + } +} diff --git a/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift b/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift new file mode 100644 index 00000000..c28bf5db --- /dev/null +++ b/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift @@ -0,0 +1,31 @@ +// +// 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 TabCoordinator { + + typealias TabItemSelectedPublisher = LegacyEventPublisher +} + +@propertyWrapper +struct TabItemSelected: DynamicProperty { + + @Environment(\.tabItemSelected) + private var publisher + + var wrappedValue: TabCoordinator.TabItemSelectedPublisher { + publisher + } +} + +extension EnvironmentValues { + + @Entry + var tabItemSelected: TabCoordinator.TabItemSelectedPublisher = .init() +} diff --git a/Shared/Coordinators/UserSignInCoordinator 2.swift b/Shared/Coordinators/UserSignInCoordinator 2.swift new file mode 100644 index 00000000..a5b978cb --- /dev/null +++ b/Shared/Coordinators/UserSignInCoordinator 2.swift @@ -0,0 +1,61 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class UserSignInCoordinator: NavigationCoordinatable { + + struct SecurityParameters { + let pinHint: Binding + let accessPolicy: Binding + } + + let stack = NavigationStack(initial: \UserSignInCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var quickConnect = makeQuickConnect + + #if os(iOS) + @Route(.push) + var security = makeSecurity + #endif + + private let server: ServerState + + init(server: ServerState) { + self.server = server + } + + func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator { + NavigationViewCoordinator { + QuickConnectView(quickConnect: quickConnect) + } + } + + #if os(iOS) + func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator { + NavigationViewCoordinator { + UserSignInView.SecurityView( + pinHint: parameters.pinHint, + accessPolicy: parameters.accessPolicy + ) + } + } + #endif + + @ViewBuilder + func makeStart() -> some View { + UserSignInView(server: server) + } +} diff --git a/Shared/Coordinators/UserSignInCoordinator 3.swift b/Shared/Coordinators/UserSignInCoordinator 3.swift new file mode 100644 index 00000000..a5b978cb --- /dev/null +++ b/Shared/Coordinators/UserSignInCoordinator 3.swift @@ -0,0 +1,61 @@ +// +// 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class UserSignInCoordinator: NavigationCoordinatable { + + struct SecurityParameters { + let pinHint: Binding + let accessPolicy: Binding + } + + let stack = NavigationStack(initial: \UserSignInCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var quickConnect = makeQuickConnect + + #if os(iOS) + @Route(.push) + var security = makeSecurity + #endif + + private let server: ServerState + + init(server: ServerState) { + self.server = server + } + + func makeQuickConnect(quickConnect: QuickConnect) -> NavigationViewCoordinator { + NavigationViewCoordinator { + QuickConnectView(quickConnect: quickConnect) + } + } + + #if os(iOS) + func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator { + NavigationViewCoordinator { + UserSignInView.SecurityView( + pinHint: parameters.pinHint, + accessPolicy: parameters.accessPolicy + ) + } + } + #endif + + @ViewBuilder + func makeStart() -> some View { + UserSignInView(server: server) + } +} diff --git a/Shared/Extensions/BlurHash.swift b/Shared/Extensions/BlurHash.swift new file mode 100644 index 00000000..7848f087 --- /dev/null +++ b/Shared/Extensions/BlurHash.swift @@ -0,0 +1,22 @@ +// +// 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 BlurHashKit +import SwiftUI + +extension BlurHash { + + var averageLinearColor: Color { + let color = averageLinearRGB + return Color( + red: Double(color.0), + green: Double(color.1), + blue: Double(color.2) + ) + } +} diff --git a/Shared/Extensions/BoxedPublished.swift b/Shared/Extensions/BoxedPublished.swift new file mode 100644 index 00000000..b10c26b7 --- /dev/null +++ b/Shared/Extensions/BoxedPublished.swift @@ -0,0 +1,33 @@ +// +// 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 + +@propertyWrapper +struct BoxedPublished: DynamicProperty { + + @StateObject + var storage: PublishedBox + + init(wrappedValue: Value) { + self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue)) + } + + var wrappedValue: Value { + get { storage.value } + nonmutating set { storage.value = newValue } + } + + var projectedValue: Published.Publisher { + storage.$value + } + + var box: PublishedBox { + storage + } +} diff --git a/Shared/Extensions/Duration.swift b/Shared/Extensions/Duration.swift new file mode 100644 index 00000000..d2db0cb0 --- /dev/null +++ b/Shared/Extensions/Duration.swift @@ -0,0 +1,32 @@ +// +// 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 +// + +@inlinable +func abs(_ d: Duration) -> Duration { + d < .zero ? (.zero - d) : d +} + +extension Duration { + + /// Represent Jellyfin ticks as a Duration + static func ticks(_ ticks: Int) -> Duration { + Duration.microseconds(Int64(ticks) / 10) + } + + var microseconds: Int64 { + (components.attoseconds / 1_000_000_000_000) + components.seconds * 1_000_000 + } + + var seconds: Double { + Double(components.seconds) + Double(components.attoseconds) * 1e-18 + } + + var ticks: Int { + Int(microseconds * 10) + } +} diff --git a/Shared/Extensions/FocusedValues.swift b/Shared/Extensions/FocusedValues.swift new file mode 100644 index 00000000..92d42d12 --- /dev/null +++ b/Shared/Extensions/FocusedValues.swift @@ -0,0 +1,15 @@ +// +// 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 FocusedValues { + + @Entry + var focusedPoster: AnyPoster? +} diff --git a/Shared/Extensions/JellyfinAPI/AnyView.swift b/Shared/Extensions/JellyfinAPI/AnyView.swift new file mode 100644 index 00000000..5ec421e4 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/AnyView.swift @@ -0,0 +1,14 @@ +// +// 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 AnyView: PlatformView { + var iOSView: some View { self } + var tvOSView: some View { self } +} diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift index b6a53241..e31585b6 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift @@ -105,6 +105,13 @@ extension BaseItemDto { } logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned") + logger.debug(" TranscodingURL: \(matchingMediaSource.transcodingURL ?? "nil")") + logger.debug(" Path: \(matchingMediaSource.path ?? "nil")") + logger.debug(" Container: \(matchingMediaSource.container ?? "nil")") + logger.debug(" SupportsDirectPlay: \(matchingMediaSource.isSupportsDirectPlay ?? false)") + logger.debug(" PlaySessionID: \(response.value.playSessionID ?? "nil")") + logger.debug(" LiveStreamID: \(matchingMediaSource.liveStreamID ?? "nil")") + logger.debug(" OpenToken: \(matchingMediaSource.openToken ?? "nil")") return try matchingMediaSource.liveVideoPlayerViewModel( with: self, playSessionID: response.value.playSessionID! diff --git a/Shared/Extensions/JellyfinAPI/CountryInfo.swift b/Shared/Extensions/JellyfinAPI/CountryInfo.swift new file mode 100644 index 00000000..d8f71a8d --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/CountryInfo.swift @@ -0,0 +1,34 @@ +// +// 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 Foundation +import JellyfinAPI + +extension CountryInfo: Displayable { + + var displayTitle: String { + if let twoLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: twoLetterISORegionName) { + return name + } + + if let threeLetterISORegionName, let name = Locale.current.localizedString(forRegionCode: threeLetterISORegionName) { + return name + } + + return displayName ?? L10n.unknown + } +} + +extension CountryInfo { + + static var none: CountryInfo { + CountryInfo( + displayName: L10n.none + ) + } +} diff --git a/Shared/Extensions/JellyfinAPI/CultureDto.swift b/Shared/Extensions/JellyfinAPI/CultureDto.swift new file mode 100644 index 00000000..a804b2cb --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/CultureDto.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension CultureDto: Displayable { + + var displayTitle: String { + if let twoLetterISOLanguageName, + let name = Locale.current.localizedString(forLanguageCode: twoLetterISOLanguageName) + { + return name + } + + if let threeLetterISOLanguageNames, let displayName = threeLetterISOLanguageNames + .compactMap({ Locale.current.localizedString(forLanguageCode: $0) }) + .first + { + return displayName + } + + return displayName ?? L10n.unknown + } + + static var none: CultureDto { + CultureDto( + displayName: L10n.none + ) + } +} diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift index 948da58a..f6de75d7 100644 --- a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift @@ -72,34 +72,91 @@ extension MediaSourceInfo { let playbackURL: URL let playMethod: PlayMethod - if let transcodingURL { - guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL) - else { throw JellyfinAPIError("Unable to construct transcoded url") } + print("🎬 liveVideoPlayerViewModel: Starting for item \(item.displayTitle)") + print("🎬 Server URL: \(userSession.server.currentURL)") + print("🎬 TranscodingURL: \(transcodingURL ?? "nil")") + print("🎬 Path: \(self.path ?? "nil")") + print("🎬 SupportsDirectPlay: \(self.isSupportsDirectPlay ?? false)") + print("🎬 MediaSourceInfo ID: \(self.id ?? "nil")") + print("🎬 MediaSourceInfo Name: \(self.name ?? "nil")") + print("🎬 Container: \(self.container ?? "nil")") + print("🎬 PlaySessionID: \(playSessionID)") + print("🎬 LiveStreamID: \(self.liveStreamID ?? "nil")") + print("🎬 OpenToken: \(self.openToken ?? "nil")") + + // For Live TV: Try direct Dispatcharr proxy URL FIRST (Jellyfin's endpoints are broken) + if let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil { + // Use direct Dispatcharr proxy stream (MPEG-TS over HTTP) + playbackURL = pathURL + playMethod = .directPlay + print("🎬 Using direct Dispatcharr proxy path: \(playbackURL)") + print("🎬 Absolute URL: \(playbackURL.absoluteString)") + } else if let transcodingURL { + // Fallback to Jellyfin transcoding URL (doesn't work for Dispatcharr channels) + let liveTranscodingURL = transcodingURL.replacingOccurrences(of: "/master.m3u8", with: "/live.m3u8") + + guard var fullTranscodeURL = userSession.client.fullURL(with: liveTranscodingURL) + else { throw JellyfinAPIError("Unable to make transcode URL") } + + // Add LiveStreamId parameter using URLComponents for proper encoding + if let openToken = self.openToken, var components = URLComponents(url: fullTranscodeURL, resolvingAgainstBaseURL: false) { + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "LiveStreamId", value: openToken)) + components.queryItems = queryItems + + if let urlWithLiveStreamId = components.url { + fullTranscodeURL = urlWithLiveStreamId + print("🎬 Added LiveStreamId parameter: \(openToken)") + } + } + playbackURL = fullTranscodeURL playMethod = .transcode - } else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) { + print("🎬 Using live transcoding URL (converted from master): \(playbackURL)") + print("🎬 Absolute URL: \(playbackURL.absoluteString)") + } else if false, let path = self.path, let pathURL = URL(string: path), pathURL.scheme != nil { + // Direct path disabled - fails with AVPlayer connection error + playbackURL = pathURL + playMethod = .directPlay + print("🎬 Using direct path URL (absolute): \(playbackURL)") + print("🎬 Absolute URL: \(playbackURL.absoluteString)") + } else if false, self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) { + // Relative direct play disabled playbackURL = playbackUrl playMethod = .directPlay + print("🎬 Using direct play URL (relative): \(playbackURL)") + print("🎬 Absolute URL: \(playbackURL.absoluteString)") } else { - let videoStreamParameters = Paths.GetVideoStreamParameters( - isStatic: true, - tag: item.etag, - playSessionID: playSessionID, - mediaSourceID: id - ) + // Use Jellyfin's live.m3u8 endpoint for Live TV (same as web browser) + // Construct URL: /videos/{id}/live.m3u8?DeviceId=...&MediaSourceId=...&PlaySessionId=...&api_key=... + let deviceId = userSession.client.configuration.deviceID ?? "unknown" + let apiKey = userSession.client.accessToken ?? "" - let videoStreamRequest = Paths.getVideoStream( - itemID: item.id!, - parameters: videoStreamParameters - ) + var urlComponents = URLComponents() + urlComponents.scheme = userSession.server.currentURL.scheme + urlComponents.host = userSession.server.currentURL.host + urlComponents.port = userSession.server.currentURL.port + urlComponents.path = "/videos/\(item.id!)/live.m3u8" + urlComponents.queryItems = [ + URLQueryItem(name: "DeviceId", value: deviceId), + URLQueryItem(name: "MediaSourceId", value: id), + URLQueryItem(name: "PlaySessionId", value: playSessionID), + URLQueryItem(name: "api_key", value: apiKey), + ] - guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else { - throw JellyfinAPIError("Unable to construct transcoded url") + guard let liveURL = urlComponents.url else { + print("🎬 ERROR: Unable to construct live.m3u8 URL") + throw JellyfinAPIError("Unable to construct live.m3u8 URL") } - playbackURL = fullURL + playbackURL = liveURL playMethod = .directPlay + print("🎬 Using live.m3u8 URL: \(playbackURL)") + print("🎬 Absolute URL: \(playbackURL.absoluteString)") } + print("🎬 Final playback URL absolute string: \(playbackURL.absoluteString)") + print("🎬 Play method: \(playMethod)") + let videoStreams = mediaStreams?.filter { $0.type == .video } ?? [] let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? [] let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? [] diff --git a/Shared/Extensions/LabelStyle/SectionFooterWithImageLabelStyle.swift b/Shared/Extensions/LabelStyle/SectionFooterWithImageLabelStyle.swift new file mode 100644 index 00000000..d75f3eaf --- /dev/null +++ b/Shared/Extensions/LabelStyle/SectionFooterWithImageLabelStyle.swift @@ -0,0 +1,35 @@ +// +// 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 + +// TODO: rename as not only used in section footers + +extension LabelStyle where Self == SectionFooterWithImageLabelStyle { + + static func sectionFooterWithImage( + imageStyle: ImageStyle + ) -> SectionFooterWithImageLabelStyle { + SectionFooterWithImageLabelStyle(imageStyle: imageStyle) + } +} + +struct SectionFooterWithImageLabelStyle: LabelStyle { + + let imageStyle: ImageStyle + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.icon + .foregroundStyle(imageStyle) + .fontWeight(.bold) + + configuration.title + } + } +} diff --git a/Shared/Extensions/Nuke/DataCache.swift b/Shared/Extensions/Nuke/DataCache.swift index e6d91940..c753a5d4 100644 --- a/Shared/Extensions/Nuke/DataCache.swift +++ b/Shared/Extensions/Nuke/DataCache.swift @@ -23,7 +23,7 @@ extension DataCache.Swiftfin { static let posters: DataCache? = { - let dataCache = try? DataCache(name: "org.ashik.jellypig/Posters") { name in + let dataCache = try? DataCache(name: "se.ashik.jellyflood/Posters") { name in guard let url = name.url else { return nil } return ImagePipeline.cacheKey(for: url) } @@ -40,7 +40,7 @@ extension DataCache.Swiftfin { return nil } - let path = root.appendingPathComponent("Caches/org.ashik.jellypig.local", isDirectory: true) + let path = root.appendingPathComponent("Caches/se.ashik.jellyflood.local", isDirectory: true) let dataCache = try? DataCache(path: path) { name in diff --git a/Shared/Extensions/ProgressViewStyle/GaugeProgressViewStyle.swift b/Shared/Extensions/ProgressViewStyle/GaugeProgressViewStyle.swift new file mode 100644 index 00000000..0b18f906 --- /dev/null +++ b/Shared/Extensions/ProgressViewStyle/GaugeProgressViewStyle.swift @@ -0,0 +1,60 @@ +// +// 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 + +struct GaugeProgressViewStyle: ProgressViewStyle { + + @Default(.accentColor) + private var accentColor + + @State + private var contentSize: CGSize = .zero + + private let lineWidthRatio: CGFloat + private let systemImage: String? + + init(systemImage: String? = nil) { + self.lineWidthRatio = systemImage == nil ? 0.2 : 0.125 + self.systemImage = systemImage + } + + func makeBody(configuration: Configuration) -> some View { + ZStack { + + if let systemImage { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: contentSize.width / 2.5, maxHeight: contentSize.height / 2.5) + .foregroundStyle(.secondary) + .padding(6) + } + + Circle() + .stroke( + Color.gray.opacity(0.2), + lineWidth: contentSize.width * lineWidthRatio + ) + + Circle() + .trim(from: 0, to: configuration.fractionCompleted ?? 0) + .stroke( + accentColor, + style: StrokeStyle( + lineWidth: contentSize.width * lineWidthRatio, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + } + .animation(.linear(duration: 0.1), value: configuration.fractionCompleted) + .trackingSize($contentSize) + } +} diff --git a/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift b/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift new file mode 100644 index 00000000..25d654e6 --- /dev/null +++ b/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift @@ -0,0 +1,60 @@ +// +// 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 PlaybackProgressViewStyle: ProgressViewStyle { + + enum CornerStyle { + case round + case square + } + + @State + private var contentSize: CGSize = .zero + + var secondaryProgress: Double? + var cornerStyle: CornerStyle + + @ViewBuilder + private func buildCapsule(for progress: Double) -> some View { + Rectangle() + .cornerRadius( + cornerStyle == .round ? contentSize.height / 2 : 0, + corners: [.topLeft, .bottomLeft] + ) + .frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height) + .offset(x: -contentSize.height) + } + + func makeBody(configuration: Configuration) -> some View { + Capsule() + .foregroundStyle(.secondary) + .opacity(0.2) + .overlay(alignment: .leading) { + ZStack(alignment: .leading) { + + if let secondaryProgress, + secondaryProgress > 0 + { + buildCapsule(for: secondaryProgress) + .foregroundStyle(.tertiary) + } + + if let fractionCompleted = configuration.fractionCompleted { + buildCapsule(for: fractionCompleted) + .foregroundStyle(.primary) + } + } + } + .trackingSize($contentSize) + .mask { + Capsule() + } + } +} diff --git a/Shared/Extensions/ProgressViewStyle/ProgressViewStyle.swift b/Shared/Extensions/ProgressViewStyle/ProgressViewStyle.swift new file mode 100644 index 00000000..2a1117a0 --- /dev/null +++ b/Shared/Extensions/ProgressViewStyle/ProgressViewStyle.swift @@ -0,0 +1,33 @@ +// +// 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 ProgressViewStyle where Self == GaugeProgressViewStyle { + + static var gauge: GaugeProgressViewStyle { + GaugeProgressViewStyle() + } + + static func gauge(systemImage: String) -> GaugeProgressViewStyle { + GaugeProgressViewStyle(systemImage: systemImage) + } +} + +extension ProgressViewStyle where Self == PlaybackProgressViewStyle { + + static var playback: Self { .init(secondaryProgress: nil, cornerStyle: .round) } + + func secondaryProgress(_ progress: Double?) -> Self { + copy(self, modifying: \.secondaryProgress, to: progress) + } + + var square: Self { + copy(self, modifying: \.cornerStyle, to: .square) + } +} diff --git a/Shared/Extensions/PublishedBox.swift b/Shared/Extensions/PublishedBox.swift new file mode 100644 index 00000000..670d774a --- /dev/null +++ b/Shared/Extensions/PublishedBox.swift @@ -0,0 +1,20 @@ +// +// 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 Combine + +/// A box for a `Published` value +class PublishedBox: ObservableObject { + + @Published + var value: Value + + init(initialValue: Value) { + self.value = initialValue + } +} diff --git a/Shared/Extensions/Section.swift b/Shared/Extensions/Section.swift new file mode 100644 index 00000000..82dd3c6e --- /dev/null +++ b/Shared/Extensions/Section.swift @@ -0,0 +1,24 @@ +// +// 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 Section where Parent == Text, Footer == Text, Content: View { + + init( + _ header: String, + footer: String, + @ViewBuilder content: @escaping () -> Content + ) { + self.init(content: content) { + Text(header) + } footer: { + Text(footer) + } + } +} diff --git a/Shared/Extensions/UIImage.swift b/Shared/Extensions/UIImage.swift new file mode 100644 index 00000000..c2efa56f --- /dev/null +++ b/Shared/Extensions/UIImage.swift @@ -0,0 +1,81 @@ +// +// 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 UIKit + +extension UIImage { + + func getTileImage( + columns: Int, + rows: Int, + index: Int + ) -> UIImage? { + let x = index % columns + let y = index / columns + + // Check if the tile index is within the valid range +// guard x >= 0, y >= 0, x < columns, y < rows else { +// return nil +// } + + // Use integer arithmetic for tile dimensions and positions + let imageWidth = Int(size.width) + let imageHeight = Int(size.height) + let tileWidth = imageWidth / columns + let tileHeight = imageHeight / rows + + // Calculate the rectangle using integer values + let rect = CGRect( + x: x * tileWidth, + y: y * tileHeight, + width: tileWidth, + height: tileHeight + ) + + // This check is now redundant because of the earlier guard statement + // guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else { + // return nil + // } + + if let cgImage = cgImage?.cropping(to: rect) { + return UIImage(cgImage: cgImage) + } + + return nil + +// guard index >= 0 else { +// return nil +// } +// +// let imageWidth = size.width +// let imageHeight = size.height +// +// let tileWidth = imageWidth / CGFloat(columns) +// let tileHeight = imageHeight / CGFloat(rows) +// +// let x = (index % columns) +// let y = (index / columns) +// +// let rect = CGRect( +// x: CGFloat(x) * tileWidth, +// y: CGFloat(y) * tileHeight, +// width: tileWidth, +// height: tileHeight +// ) +// +// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else { +// return nil +// } +// +// if let cgImage = cgImage?.cropping(to: rect) { +// return UIImage(cgImage: cgImage) +// } +// +// return nil + } +} diff --git a/Shared/Extensions/UnitPoint.swift b/Shared/Extensions/UnitPoint.swift new file mode 100644 index 00000000..63166906 --- /dev/null +++ b/Shared/Extensions/UnitPoint.swift @@ -0,0 +1,16 @@ +// +// 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 UnitPoint { + + var inverted: UnitPoint { + UnitPoint(x: 1 - x, y: 1 - y) + } +} diff --git a/Shared/Logging/Logging.swift b/Shared/Logging/Logging.swift new file mode 100644 index 00000000..0c7ecfb9 --- /dev/null +++ b/Shared/Logging/Logging.swift @@ -0,0 +1,73 @@ +// +// 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 Logging + +extension Logger { + + static func swiftfin() -> Logger { + Logger(label: "org.jellyfin.swiftfin") + } +} + +struct SwiftfinConsoleHandler: LogHandler { + + var logLevel: Logger.Level = .trace + var metadata: Logger.Metadata = [:] + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { + metadata[key] + } + set(newValue) { + metadata[key] = newValue + } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let line = "[\(level.emoji) \(level.rawValue.capitalized)] \(file.shortFileName)#\(line):\(function) \(message)" + let meta = (metadata ?? [:]).merging(self.metadata) { _, new in new } + let metadataString = meta.map { "\t- \($0): \($1)" }.joined(separator: "\n") + + print(line) + + if metadataString.isNotEmpty { + print(metadataString) + } + } +} + +extension Logger.Level { + var emoji: String { + switch self { + case .trace: + return "🟣" + case .debug: + return "🔵" + case .info: + return "🟢" + case .notice: + return "🟠" + case .warning: + return "🟡" + case .error: + return "🔴" + case .critical: + return "💥" + } + } +} diff --git a/Shared/Logging/NetworkLogger.swift b/Shared/Logging/NetworkLogger.swift new file mode 100644 index 00000000..555f3d0e --- /dev/null +++ b/Shared/Logging/NetworkLogger.swift @@ -0,0 +1,57 @@ +// +// 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 Foundation +import JellyfinAPI +import Pulse + +private let redactedMessage = "" + +extension NetworkLogger { + + static func swiftfin() -> NetworkLogger { + var configuration = NetworkLogger.Configuration() + + configuration.willHandleEvent = { event -> LoggerStore.Event? in + if case var LoggerStore.Event.networkTaskCompleted(task) = event { + guard let url = task.originalRequest.url, + let requestBody = task.requestBody + else { + return event + } + + let pathComponents = url.pathComponents + + if pathComponents.last == "AuthenticateByName", + var body = try? JSONDecoder().decode(AuthenticateUserByName.self, from: requestBody) + { + body.pw = redactedMessage + task.requestBody = try? JSONEncoder().encode(body) + + return LoggerStore.Event.networkTaskCompleted(task) + } + + if pathComponents.last == "Password", + var body = try? JSONDecoder().decode(UpdateUserPassword.self, from: requestBody) + { + body.currentPassword = redactedMessage + body.currentPw = redactedMessage + body.newPw = redactedMessage + body.isResetPassword = nil + task.requestBody = try? JSONEncoder().encode(body) + + return LoggerStore.Event.networkTaskCompleted(task) + } + } + + return event + } + + return NetworkLogger(configuration: configuration) + } +} diff --git a/Shared/Logging/SwiftfinCorestoreLogger.swift b/Shared/Logging/SwiftfinCorestoreLogger.swift new file mode 100644 index 00000000..ca3fb275 --- /dev/null +++ b/Shared/Logging/SwiftfinCorestoreLogger.swift @@ -0,0 +1,84 @@ +// +// 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 CoreStore +import Logging + +struct SwiftfinCorestoreLogger: CoreStoreLogger { + + private let logger = Logger.swiftfin() + + func log( + error: CoreStoreError, + message: String, + fileName: StaticString, + lineNumber: Int, + functionName: StaticString + ) { + logger.error( + "\(message)", + metadata: nil, + source: "Corestore", + file: fileName.description, + function: functionName.description, + line: UInt(lineNumber) + ) + } + + func log( + level: LogLevel, + message: String, + fileName: StaticString, + lineNumber: Int, + functionName: StaticString + ) { + logger.log( + level: level.asSwiftLog, + "\(message)", + metadata: nil, + source: "Corestore", + file: fileName.description, + function: functionName.description, + line: UInt(lineNumber) + ) + } + + func assert( + _ condition: @autoclosure () -> Bool, + message: @autoclosure () -> String, + fileName: StaticString, + lineNumber: Int, + functionName: StaticString + ) { + guard !condition() else { return } + logger.critical( + "\(message())", + metadata: nil, + source: "Corestore", + file: fileName.description, + function: functionName.description, + line: UInt(lineNumber) + ) + } +} + +extension CoreStore.LogLevel { + + var asSwiftLog: Logger.Level { + switch self { + case .trace: + return .trace + case .notice: + return .debug + case .warning: + return .warning + case .fatal: + return .critical + } + } +} diff --git a/Shared/Objects/ActiveSessionFilter.swift b/Shared/Objects/ActiveSessionFilter.swift new file mode 100644 index 00000000..32668cbe --- /dev/null +++ b/Shared/Objects/ActiveSessionFilter.swift @@ -0,0 +1,38 @@ +// +// 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 + +enum ActiveSessionFilter: String, CaseIterable, SystemImageable, Displayable, Storable { + + case all + case active + case inactive + + var displayTitle: String { + switch self { + case .all: + return L10n.all + case .active: + return L10n.active + case .inactive: + return L10n.inactive + } + } + + var systemImage: String { + switch self { + case .all: + return "line.3.horizontal" + case .active: + return "play" + case .inactive: + return "play.slash" + } + } +} diff --git a/Shared/Objects/ChannelProgram.swift b/Shared/Objects/ChannelProgram.swift index d5fd2e77..297fafd6 100644 --- a/Shared/Objects/ChannelProgram.swift +++ b/Shared/Objects/ChannelProgram.swift @@ -53,4 +53,20 @@ extension ChannelProgram: Poster { var systemImage: String { channel.systemImage } + + func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + channel.portraitImageSources(maxWidth: maxWidth) + } + + func landscapeImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + channel.landscapeImageSources(maxWidth: maxWidth) + } + + func cinematicImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + channel.cinematicImageSources(maxWidth: maxWidth) + } + + func squareImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + channel.squareImageSources(maxWidth: maxWidth) + } } diff --git a/Shared/Objects/DirectionalPanGestureRecognizer.swift b/Shared/Objects/DirectionalPanGestureRecognizer.swift new file mode 100644 index 00000000..39fe09f9 --- /dev/null +++ b/Shared/Objects/DirectionalPanGestureRecognizer.swift @@ -0,0 +1,44 @@ +// +// 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 UIKit + +class DirectionalPanGestureRecognizer: UIPanGestureRecognizer { + + var direction: Direction + + init(direction: Direction, target: AnyObject, action: Selector) { + self.direction = direction + super.init(target: target, action: action) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if state == .began { + let velocity = velocity(in: view) + + let isUp = velocity.y < 0 + let isHorizontal = velocity.y.magnitude < velocity.x.magnitude + let isVertical = velocity.x.magnitude < velocity.y.magnitude + + switch direction { + case .all: () + case .allButDown where isUp || isHorizontal: () + case .horizontal where isHorizontal: () + case .vertical where isVertical: () + case .up where isVertical && velocity.y < 0: () + case .down where isVertical && velocity.y > 0: () + case .left where isHorizontal && velocity.x < 0: () + case .right where isHorizontal && velocity.x > 0: () + default: + state = .cancelled + } + } + } +} diff --git a/Shared/Objects/GestureAction/DoubleTouchGestureAction.swift b/Shared/Objects/GestureAction/DoubleTouchGestureAction.swift new file mode 100644 index 00000000..b048da9a --- /dev/null +++ b/Shared/Objects/GestureAction/DoubleTouchGestureAction.swift @@ -0,0 +1,28 @@ +// +// 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 +// + +enum DoubleTouchGestureAction: String, GestureAction { + + case none + case aspectFill + case gestureLock + case pausePlay + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .aspectFill: + return L10n.aspectFill + case .gestureLock: + return L10n.gestureLock + case .pausePlay: + return L10n.playAndPause + } + } +} diff --git a/Shared/Objects/GestureAction/GestureAction.swift b/Shared/Objects/GestureAction/GestureAction.swift new file mode 100644 index 00000000..839433df --- /dev/null +++ b/Shared/Objects/GestureAction/GestureAction.swift @@ -0,0 +1,12 @@ +// +// 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 +// + +// `none` is used since values aren't supported in Defaults +// https://github.com/sindresorhus/Defaults/issues/54 + +protocol GestureAction: CaseIterable, Displayable, Storable {} diff --git a/Shared/Objects/GestureAction/LongPressGestureAction.swift b/Shared/Objects/GestureAction/LongPressGestureAction.swift new file mode 100644 index 00000000..73ee2806 --- /dev/null +++ b/Shared/Objects/GestureAction/LongPressGestureAction.swift @@ -0,0 +1,22 @@ +// +// 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 +// + +enum LongPressGestureAction: String, GestureAction { + + case none + case gestureLock + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .gestureLock: + return L10n.gestureLock + } + } +} diff --git a/Shared/Objects/GestureAction/MultiTapGestureAction.swift b/Shared/Objects/GestureAction/MultiTapGestureAction.swift new file mode 100644 index 00000000..e6d0fdb8 --- /dev/null +++ b/Shared/Objects/GestureAction/MultiTapGestureAction.swift @@ -0,0 +1,22 @@ +// +// 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 +// + +enum MultiTapGestureAction: String, GestureAction { + + case none + case jump + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .jump: + return L10n.jump + } + } +} diff --git a/Shared/Objects/GestureAction/PanGestureAction.swift b/Shared/Objects/GestureAction/PanGestureAction.swift new file mode 100644 index 00000000..15842d20 --- /dev/null +++ b/Shared/Objects/GestureAction/PanGestureAction.swift @@ -0,0 +1,31 @@ +// +// 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 +// + +enum PanGestureAction: String, GestureAction { + + case none + case brightness + case scrub + case slowScrub + case volume + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .brightness: + return L10n.brightness + case .scrub: + return L10n.scrub + case .slowScrub: + return L10n.slowScrub + case .volume: + return L10n.volume + } + } +} diff --git a/Shared/Objects/GestureAction/PinchGestureAction.swift b/Shared/Objects/GestureAction/PinchGestureAction.swift new file mode 100644 index 00000000..6b66e8b1 --- /dev/null +++ b/Shared/Objects/GestureAction/PinchGestureAction.swift @@ -0,0 +1,22 @@ +// +// 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 +// + +enum PinchGestureAction: String, GestureAction { + + case none + case aspectFill + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .aspectFill: + return L10n.aspectFill + } + } +} diff --git a/Shared/Objects/GestureAction/SwipeGestureAction.swift b/Shared/Objects/GestureAction/SwipeGestureAction.swift new file mode 100644 index 00000000..4aedb8e0 --- /dev/null +++ b/Shared/Objects/GestureAction/SwipeGestureAction.swift @@ -0,0 +1,22 @@ +// +// 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 +// + +enum SwipeGestureAction: String, GestureAction { + + case none + case jump + + var displayTitle: String { + switch self { + case .none: + return L10n.none + case .jump: + return L10n.jump + } + } +} diff --git a/Shared/Objects/IsStatusBarHiddenKey.swift b/Shared/Objects/IsStatusBarHiddenKey.swift new file mode 100644 index 00000000..0b064cd4 --- /dev/null +++ b/Shared/Objects/IsStatusBarHiddenKey.swift @@ -0,0 +1,17 @@ +// +// 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 IsStatusBarHiddenKey: PreferenceKey { + static var defaultValue: Bool = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() || value + } +} diff --git a/Shared/Objects/LazyState.swift b/Shared/Objects/LazyState.swift new file mode 100644 index 00000000..c02cf80c --- /dev/null +++ b/Shared/Objects/LazyState.swift @@ -0,0 +1,54 @@ +// +// 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 + +@MainActor +@propertyWrapper +struct LazyState: @preconcurrency DynamicProperty { + + final class Box { + + private var value: Value! + private let thunk: () -> Value + var didThunk = false + + var wrappedValue: Value { + value + } + + func setup() { + value = thunk() + didThunk = true + } + + init(wrappedValue thunk: @autoclosure @escaping () -> Value) { + self.thunk = thunk + } + } + + @State + private var holder: Box + + var wrappedValue: Value { + holder.wrappedValue + } + + var projectedValue: Binding { + Binding(get: { wrappedValue }, set: { _ in }) + } + + func update() { + guard !holder.didThunk else { return } + holder.setup() + } + + init(wrappedValue thunk: @autoclosure @escaping () -> Value) { + _holder = State(wrappedValue: Box(wrappedValue: thunk())) + } +} diff --git a/Shared/Objects/MediaJumpInterval.swift b/Shared/Objects/MediaJumpInterval.swift new file mode 100644 index 00000000..f7554349 --- /dev/null +++ b/Shared/Objects/MediaJumpInterval.swift @@ -0,0 +1,83 @@ +// +// 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 Foundation + +// TODO: conform to `SystemImageable` +// - forward to systemImage, backward to secondarySystemImage +enum MediaJumpInterval: Storable, RawRepresentable { + + typealias RawValue = Duration + + case five + case ten + case fifteen + case thirty + case custom(interval: Duration) + + init?(rawValue: Duration) { + switch rawValue { + case .seconds(5): + self = .five + case .seconds(10): + self = .ten + case .seconds(15): + self = .fifteen + case .seconds(30): + self = .thirty + default: + self = .custom(interval: rawValue) + } + } + + var rawValue: Duration { + switch self { + case .five: + .seconds(5) + case .ten: + .seconds(10) + case .fifteen: + .seconds(15) + case .thirty: + .seconds(30) + case let .custom(interval): + interval + } + } + + var forwardSystemImage: String { + switch self { + case .thirty: + "goforward.30" + case .fifteen: + "goforward.15" + case .ten: + "goforward.10" + case .five: + "goforward.5" + case .custom: + "goforward" + } + } + + var backwardSystemImage: String { + switch self { + case .thirty: + "gobackward.30" + case .fifteen: + "gobackward.15" + case .ten: + "gobackward.10" + case .five: + "gobackward.5" + case .custom: + "gobackward" + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift new file mode 100644 index 00000000..6748b7dc --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift @@ -0,0 +1,218 @@ +// +// 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 Foundation +import JellyfinAPI +import Logging + +// TODO: build report of determined values for playback information +// - transcode, video stream, path + +extension MediaPlayerItem { + + /// The main `MediaPlayerItem` builder for normal online usage. + static func build( + for initialItem: BaseItemDto, + mediaSource _initialMediaSource: MediaSourceInfo? = nil, + videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType], + requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate], + compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode], + modifyItem: ((inout BaseItemDto) -> Void)? = nil + ) async throws -> MediaPlayerItem { + + let logger = Logger.swiftfin() + + guard let itemID = initialItem.id else { + logger.critical("No item ID!") + throw JellyfinAPIError(L10n.unknownError) + } + + guard let userSession = Container.shared.currentUserSession() else { + logger.critical("No user session!") + throw JellyfinAPIError(L10n.unknownError) + } + + var item = try await initialItem.getFullItem(userSession: userSession) + + if let modifyItem { + modifyItem(&item) + } + + guard let initialMediaSource = { + if let _initialMediaSource { + return _initialMediaSource + } + + if let first = item.mediaSources?.first { + logger.trace("Using first media source for item \(itemID)") + return first + } + + return nil + }() else { + logger.error("No media sources for item \(itemID)!") + throw JellyfinAPIError(L10n.unknownError) + } + + let maxBitrate = try await requestedBitrate.getMaxBitrate() + + let deviceProfile = DeviceProfile.build( + for: videoPlayerType, + compatibilityMode: compatibilityMode, + maxBitrate: maxBitrate + ) + + var playbackInfo = PlaybackInfoDto() + playbackInfo.isAutoOpenLiveStream = true + playbackInfo.deviceProfile = deviceProfile + playbackInfo.liveStreamID = initialMediaSource.liveStreamID + playbackInfo.maxStreamingBitrate = maxBitrate + playbackInfo.userID = userSession.user.id + + let request = Paths.getPostedPlaybackInfo( + itemID: itemID, + playbackInfo + ) + + let response = try await userSession.client.send(request) + + let mediaSource: MediaSourceInfo? = { + + guard let mediaSources = response.value.mediaSources else { return nil } + + if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) { + return matchingTag + } + + for source in mediaSources { + if let openToken = source.openToken, + let id = source.id, + openToken.contains(id) + { + return source + } + } + + logger.warning("Unable to find matching media source, defaulting to first media source") + + return mediaSources.first + }() + + guard let mediaSource else { + throw JellyfinAPIError("Unable to find media source for item") + } + + guard let playSessionID = response.value.playSessionID else { + throw JellyfinAPIError("No associated play session ID") + } + + let playbackURL = try Self.streamURL( + item: item, + mediaSource: mediaSource, + playSessionID: playSessionID, + userSession: userSession, + logger: logger + ) + + let previewImageProvider: (any PreviewImageProvider)? = { + let previewImageScrubbingSetting = StoredValues[.User.previewImageScrubbing] + lazy var chapterPreviewImageProvider: ChapterPreviewImageProvider? = { + if let chapters = item.fullChapterInfo, chapters.isNotEmpty { + return ChapterPreviewImageProvider(chapters: chapters) + } + return nil + }() + + if case let PreviewImageScrubbingOption.trickplay(fallbackToChapters: fallbackToChapters) = previewImageScrubbingSetting { + if let mediaSourceID = mediaSource.id, + let trickplayInfo = item.trickplay?[mediaSourceID]?.first + { + return TrickplayPreviewImageProvider( + info: trickplayInfo.value, + itemID: itemID, + mediaSourceID: mediaSourceID, + runtime: item.runtime ?? .zero + ) + } + + if fallbackToChapters { + return chapterPreviewImageProvider + } + } else if previewImageScrubbingSetting == .chapters { + return chapterPreviewImageProvider + } + + return nil + }() + + return .init( + baseItem: item, + mediaSource: mediaSource, + playSessionID: playSessionID, + url: playbackURL, + requestedBitrate: requestedBitrate, + previewImageProvider: previewImageProvider, + thumbnailProvider: item.getNowPlayingImage + ) + } + + // TODO: audio type stream + // TODO: build live tv stream from Paths.getLiveHlsStream? + private static func streamURL( + item: BaseItemDto, + mediaSource: MediaSourceInfo, + playSessionID: String, + userSession: UserSession, + logger: Logger + ) throws -> URL { + + guard let itemID = item.id else { + throw JellyfinAPIError("No item ID while building online media player item!") + } + + if let transcodingURL = mediaSource.transcodingURL { + logger.trace("Using transcoding URL for item \(itemID)") + + guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL) + else { throw JellyfinAPIError("Unable to make transcode URL") } + return fullTranscodeURL + } + + if item.mediaType == .video, !item.isLiveStream { + + logger.trace("Making video stream URL for item \(itemID)") + + let videoStreamParameters = Paths.GetVideoStreamParameters( + isStatic: true, + tag: item.etag, + playSessionID: playSessionID, + mediaSourceID: itemID + ) + + let videoStreamRequest = Paths.getVideoStream( + itemID: itemID, + parameters: videoStreamParameters + ) + + guard let videoStreamURL = userSession.client.fullURL(with: videoStreamRequest) + else { throw JellyfinAPIError("Unable to make video stream URL") } + + return videoStreamURL + } + + logger.trace("Using media source path for item \(itemID)") + + guard let path = mediaSource.path, let streamURL = URL( + string: path + ) else { throw JellyfinAPIError("Unable to make stream URL") } + + return streamURL + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem.swift new file mode 100644 index 00000000..faec27f8 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem.swift @@ -0,0 +1,102 @@ +// +// 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 + +// TODO: get preview image for current manager seconds? +// - would make scrubbing image possibly ready before scrubbing +// TODO: fix leaks +// - made from publishers of observers not being cancelled + +@MainActor +class MediaPlayerItem: ViewModel, MediaPlayerObserver { + + typealias ThumbnailProvider = () async -> UIImage? + + @Published + var selectedAudioStreamIndex: Int? = nil { + didSet { + if let proxy = manager?.proxy as? any VideoMediaPlayerProxy { + proxy.setAudioStream(.init(index: selectedAudioStreamIndex)) + } + } + } + + @Published + var selectedSubtitleStreamIndex: Int? = nil { + didSet { + if let proxy = manager?.proxy as? any VideoMediaPlayerProxy { + proxy.setSubtitleStream(.init(index: selectedSubtitleStreamIndex)) + } + } + } + + weak var manager: MediaPlayerManager? { + didSet { + for var o in observers { + o.manager = manager + } + } + } + + var observers: [any MediaPlayerObserver] = [] + + let baseItem: BaseItemDto + let mediaSource: MediaSourceInfo + let playSessionID: String + let previewImageProvider: (any PreviewImageProvider)? + let thumbnailProvider: ThumbnailProvider? + let url: URL + + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let videoStreams: [MediaStream] + + let requestedBitrate: PlaybackBitrate + + // MARK: init + + init( + baseItem: BaseItemDto, + mediaSource: MediaSourceInfo, + playSessionID: String, + url: URL, + requestedBitrate: PlaybackBitrate = .max, + previewImageProvider: (any PreviewImageProvider)? = nil, + thumbnailProvider: ThumbnailProvider? = nil + ) { + self.baseItem = baseItem + self.mediaSource = mediaSource + self.playSessionID = playSessionID + self.requestedBitrate = requestedBitrate + self.previewImageProvider = previewImageProvider + self.thumbnailProvider = thumbnailProvider + self.url = url + + let adjustedMediaStreams = mediaSource.mediaStreams?.adjustedTrackIndexes( + for: mediaSource.transcodingURL == nil ? .directPlay : .transcode, + selectedAudioStreamIndex: mediaSource.defaultAudioStreamIndex ?? 0 + ) + + let audioStreams = adjustedMediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = adjustedMediaStreams?.filter { $0.type == .subtitle } ?? [] + let videoStreams = adjustedMediaStreams?.filter { $0.type == .video } ?? [] + + self.audioStreams = audioStreams + self.subtitleStreams = subtitleStreams + self.videoStreams = videoStreams + + super.init() + + selectedAudioStreamIndex = mediaSource.defaultAudioStreamIndex ?? -1 + selectedSubtitleStreamIndex = mediaSource.defaultSubtitleStreamIndex ?? -1 + + observers.append(MediaProgressObserver(item: self)) + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift new file mode 100644 index 00000000..4f893acf --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.swift @@ -0,0 +1,240 @@ +// +// 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 AVFoundation +import Combine +import Defaults +import Foundation +import JellyfinAPI +import SwiftUI + +// TODO: After NativeVideoPlayer is removed, can move bindings and +// observers to AVPlayerView, like the VLC delegate +// - wouldn't need to have MediaPlayerProxy: MediaPlayerObserver +// TODO: report playback information, see VLCUI.PlaybackInformation (dropped frames, etc.) +// TODO: report buffering state +// TODO: have set seconds with completion handler + +@MainActor +class AVMediaPlayerProxy: VideoMediaPlayerProxy { + + let isBuffering: PublishedBox = .init(initialValue: false) + var isScrubbing: Binding = .constant(false) + var scrubbedSeconds: Binding = .constant(.zero) + var videoSize: PublishedBox = .init(initialValue: .zero) + + let avPlayerLayer: AVPlayerLayer + let player: AVPlayer + +// private var rateObserver: NSKeyValueObservation! + private var statusObserver: NSKeyValueObservation! + private var timeControlStatusObserver: NSKeyValueObservation! + private var timeObserver: Any! + private var managerItemObserver: AnyCancellable? + private var managerStateObserver: AnyCancellable? + + weak var manager: MediaPlayerManager? { + didSet { + if let manager { + managerItemObserver = manager.$playbackItem + .sink { playbackItem in + if let playbackItem { + self.playNew(item: playbackItem) + } + } + + managerStateObserver = manager.$state + .sink { state in + switch state { + case .stopped: + self.playbackStopped() + default: break + } + } + } else { + managerItemObserver?.cancel() + managerStateObserver?.cancel() + } + } + } + + init() { + self.player = AVPlayer() + self.avPlayerLayer = AVPlayerLayer(player: player) + + timeObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 1, preferredTimescale: 1000), + queue: .main + ) { newTime in + let newSeconds = Duration.seconds(newTime.seconds) + + if !self.isScrubbing.wrappedValue { + self.scrubbedSeconds.wrappedValue = newSeconds + } + + self.manager?.seconds = newSeconds + } + } + + func play() { + player.play() + } + + func pause() { + player.pause() + } + + func stop() { + player.pause() + } + + func jumpForward(_ seconds: Duration) { + let currentTime = player.currentTime() + let newTime = currentTime + CMTime(seconds: seconds.seconds, preferredTimescale: 1) + player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero) + } + + func jumpBackward(_ seconds: Duration) { + let currentTime = player.currentTime() + let newTime = max(.zero, currentTime - CMTime(seconds: seconds.seconds, preferredTimescale: 1)) + player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero) + } + + func setSeconds(_ seconds: Duration) { + let time = CMTime(seconds: seconds.seconds, preferredTimescale: 1) + player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) + } + + // TODO: complete + func setRate(_ rate: Float) {} + func setAudioStream(_ stream: MediaStream) {} + func setSubtitleStream(_ stream: MediaStream) {} + + func setAspectFill(_ aspectFill: Bool) { + avPlayerLayer.videoGravity = aspectFill ? .resizeAspectFill : .resizeAspect + } + + var videoPlayerBody: some View { + AVPlayerView() + .environmentObject(self) + } +} + +extension AVMediaPlayerProxy { + + private func playbackStopped() { + player.pause() + guard let timeObserver else { return } + player.removeTimeObserver(timeObserver) +// rateObserver.invalidate() + statusObserver.invalidate() + timeControlStatusObserver.invalidate() + } + + private func playNew(item: MediaPlayerItem) { + let baseItem = item.baseItem + + let newAVPlayerItem = AVPlayerItem(url: item.url) + newAVPlayerItem.externalMetadata = item.baseItem.avMetadata + + player.replaceCurrentItem(with: newAVPlayerItem) + + // TODO: protect against paused +// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in +// DispatchQueue.main.async { +// self.manager?.set(rate: value.newValue ?? 1.0) +// } +// } + + timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { player, _ in + let timeControlStatus = player.timeControlStatus + + DispatchQueue.main.async { + switch timeControlStatus { + case .paused: + self.manager?.setPlaybackRequestStatus(status: .paused) + case .waitingToPlayAtSpecifiedRate: () + // TODO: buffering + case .playing: + self.manager?.setPlaybackRequestStatus(status: .playing) + @unknown default: () + } + } + } + + // TODO: proper handling of none/unknown states + statusObserver = player.observe(\.currentItem?.status, options: [.new, .initial]) { _, value in + guard let newValue = value.newValue else { return } + switch newValue { + case .failed: + if let error = self.player.error { + DispatchQueue.main.async { + self.manager?.error(JellyfinAPIError("AVPlayer error: \(error.localizedDescription)")) + } + } + case .none, .readyToPlay, .unknown: + let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset])) + + self.player.seek( + to: CMTimeMake( + value: startSeconds.components.seconds, + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + @unknown default: () + } + } + } +} + +// MARK: - AVPlayerView + +extension AVMediaPlayerProxy { + + struct AVPlayerView: UIViewRepresentable { + + @EnvironmentObject + private var proxy: AVMediaPlayerProxy + @EnvironmentObject + private var scrubbedSeconds: PublishedBox + + func makeUIView(context: Context) -> UIView { +// proxy.isScrubbing = context.environment.isScrubbing +// proxy.scrubbedSeconds = $scrubbedSeconds.value + UIAVPlayerView(proxy: proxy) + } + + func updateUIView(_ uiView: UIView, context: Context) {} + } + + private class UIAVPlayerView: UIView { + + let proxy: AVMediaPlayerProxy + + init(proxy: AVMediaPlayerProxy) { + self.proxy = proxy + super.init(frame: .zero) + layer.addSublayer(proxy.avPlayerLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + proxy.avPlayerLayer.frame = bounds + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+VLC.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+VLC.swift new file mode 100644 index 00000000..6e4d9d8b --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+VLC.swift @@ -0,0 +1,230 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI +import VLCUI + +class VLCMediaPlayerProxy: VideoMediaPlayerProxy, + MediaPlayerOffsetConfigurable, + MediaPlayerSubtitleConfigurable +{ + + let isBuffering: PublishedBox = .init(initialValue: false) + let videoSize: PublishedBox = .init(initialValue: .zero) + let vlcUIProxy: VLCVideoPlayer.Proxy = .init() + + weak var manager: MediaPlayerManager? { + didSet { + for var o in observers { + o.manager = manager + } + } + } + + var observers: [any MediaPlayerObserver] = [ + NowPlayableObserver(), + ] + + func play() { + vlcUIProxy.play() + } + + func pause() { + vlcUIProxy.pause() + } + + func stop() { + vlcUIProxy.stop() + } + + func jumpForward(_ seconds: Duration) { + vlcUIProxy.jumpForward(seconds) + } + + func jumpBackward(_ seconds: Duration) { + vlcUIProxy.jumpBackward(seconds) + } + + func setRate(_ rate: Float) { + vlcUIProxy.setRate(.absolute(rate)) + } + + func setSeconds(_ seconds: Duration) { + vlcUIProxy.setSeconds(seconds) + } + + func setAudioStream(_ stream: MediaStream) { + vlcUIProxy.setAudioTrack(.absolute(stream.index ?? -1)) + } + + func setSubtitleStream(_ stream: MediaStream) { + vlcUIProxy.setSubtitleTrack(.absolute(stream.index ?? -1)) + } + + func setAspectFill(_ aspectFill: Bool) { + vlcUIProxy.aspectFill(aspectFill ? 1 : 0) + } + + func setAudioOffset(_ seconds: Duration) { + vlcUIProxy.setAudioDelay(seconds) + } + + func setSubtitleOffset(_ seconds: Duration) { + vlcUIProxy.setSubtitleDelay(seconds) + } + + func setSubtitleColor(_ color: Color) { + vlcUIProxy.setSubtitleColor(.absolute(color.uiColor)) + } + + func setSubtitleFontName(_ fontName: String) { + vlcUIProxy.setSubtitleFont(fontName) + } + + func setSubtitleFontSize(_ fontSize: Int) { + vlcUIProxy.setSubtitleSize(.absolute(fontSize)) + } + + @ViewBuilder + var videoPlayerBody: some View { + VLCPlayerView() + .environmentObject(vlcUIProxy) + } +} + +extension VLCMediaPlayerProxy { + + struct VLCPlayerView: View { + + @Default(.VideoPlayer.Subtitle.subtitleColor) + private var subtitleColor + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + @Default(.VideoPlayer.Subtitle.subtitleSize) + private var subtitleSize + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var proxy: VLCVideoPlayer.Proxy + + private var isScrubbing: Bool { + containerState.isScrubbing + } + + private func vlcConfiguration(for item: MediaPlayerItem) -> VLCVideoPlayer.Configuration { + let baseItem = item.baseItem + let mediaSource = item.mediaSource + + var configuration = VLCVideoPlayer.Configuration(url: item.url) + configuration.autoPlay = true + + let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset])) + + if !baseItem.isLiveStream { + configuration.startSeconds = startSeconds + configuration.audioIndex = .absolute(mediaSource.defaultAudioStreamIndex ?? -1) + configuration.subtitleIndex = .absolute(mediaSource.defaultSubtitleStreamIndex ?? -1) + } + + configuration.subtitleSize = .absolute(25 - Defaults[.VideoPlayer.Subtitle.subtitleSize]) + configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor) + + if let font = UIFont(name: Defaults[.VideoPlayer.Subtitle.subtitleFontName], size: 1) { + configuration.subtitleFont = .absolute(font) + } + + configuration.playbackChildren = item.subtitleStreams + .filter { $0.deliveryMethod == .external } + .compactMap(\.asVLCPlaybackChild) + + return configuration + } + + var body: some View { + if let playbackItem = manager.playbackItem, manager.state != .stopped { + VLCVideoPlayer(configuration: vlcConfiguration(for: playbackItem)) + .proxy(proxy) + .onSecondsUpdated { newSeconds, info in + if !isScrubbing { + containerState.scrubbedSeconds.value = newSeconds + } + + manager.seconds = newSeconds + + if let proxy = manager.proxy as? any VideoMediaPlayerProxy { + proxy.videoSize.value = info.videoSize + } + } + .onStateUpdated { state, info in + manager.logger.trace("VLC state updated: \(state)") + + switch state { + case .buffering, + .esAdded, + .opening: + // TODO: figure out when to properly set to false + manager.proxy?.isBuffering.value = true + case .ended: + // Live streams will send stopped/ended events + guard !playbackItem.baseItem.isLiveStream else { return } + manager.proxy?.isBuffering.value = false + manager.ended() + case .stopped: () + // Stopped is ignored as the `MediaPlayerManager` + // should instead call this to be stopped, rather + // than react to the event. + case .error: + manager.proxy?.isBuffering.value = false + manager.error(JellyfinAPIError("VLC player is unable to perform playback")) + case .playing: + manager.proxy?.isBuffering.value = false + manager.setPlaybackRequestStatus(status: .playing) + case .paused: + manager.setPlaybackRequestStatus(status: .paused) + } + + if let proxy = manager.proxy as? any VideoMediaPlayerProxy { + proxy.videoSize.value = info.videoSize + } + } + .onReceive(manager.$playbackItem) { playbackItem in + guard let playbackItem else { return } + proxy.playNewMedia(vlcConfiguration(for: playbackItem)) + } + .backport + .onChange(of: manager.rate) { _, newValue in + proxy.setRate(.absolute(newValue)) + } + .backport + .onChange(of: subtitleColor) { _, newValue in + if let proxy = proxy as? MediaPlayerSubtitleConfigurable { + proxy.setSubtitleColor(newValue) + } + } + .backport + .onChange(of: subtitleFontName) { _, newValue in + if let proxy = proxy as? MediaPlayerSubtitleConfigurable { + proxy.setSubtitleFontName(newValue) + } + } + .backport + .onChange(of: subtitleSize) { _, newValue in + if let proxy = proxy as? MediaPlayerSubtitleConfigurable { + proxy.setSubtitleFontSize(25 - newValue) + } + } + } + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy.swift new file mode 100644 index 00000000..6bafeafc --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy.swift @@ -0,0 +1,59 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +// TODO: feature implementations +// - PiP +// TODO: Chromecast proxy + +/// The proxy for top-down communication to an +/// underlying media player +protocol MediaPlayerProxy: ObservableObject, MediaPlayerObserver { + + var isBuffering: PublishedBox { get } + + func play() + func pause() + func stop() + + func jumpForward(_ seconds: Duration) + func jumpBackward(_ seconds: Duration) + func setRate(_ rate: Float) + func setSeconds(_ seconds: Duration) +} + +@MainActor +protocol VideoMediaPlayerProxy: MediaPlayerProxy { + + associatedtype VideoPlayerBody: View + + var videoSize: PublishedBox { get } + + // TODO: remove when container view handles aspect fill + func setAspectFill(_ aspectFill: Bool) + func setAudioStream(_ stream: MediaStream) + func setSubtitleStream(_ stream: MediaStream) + + @ViewBuilder + @MainActor + var videoPlayerBody: Self.VideoPlayerBody { get } +} + +protocol MediaPlayerOffsetConfigurable { + func setAudioOffset(_ seconds: Duration) + func setSubtitleOffset(_ seconds: Duration) +} + +protocol MediaPlayerSubtitleConfigurable { + func setSubtitleColor(_ color: Color) + func setSubtitleFontName(_ fontName: String) + func setSubtitleFontSize(_ fontSize: Int) +} diff --git a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableCommand.swift b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableCommand.swift new file mode 100644 index 00000000..be199a2f --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableCommand.swift @@ -0,0 +1,115 @@ +// +// 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 Foundation +import MediaPlayer + +enum NowPlayableCommand: CaseIterable { + + // Play/Pause + case pause + case play + case stop + case togglePausePlay + + // Track + case nextTrack + case previousTrack + case changeRepeatMode + case changeShuffleMode + + // Seeking/Rate + case changePlaybackRate + case seekBackward + case seekForward + case skipBackward + case skipForward + case changePlaybackPosition + + // Like/Dislike + case rating + case like + case dislike + + // Bookmark + case bookmark + + // Languages + case enableLanguageOption + case disableLanguageOption + + var remoteCommand: MPRemoteCommand { + let remoteCommandCenter = MPRemoteCommandCenter.shared() + + switch self { + case .pause: + return remoteCommandCenter.pauseCommand + case .play: + return remoteCommandCenter.playCommand + case .stop: + return remoteCommandCenter.stopCommand + case .togglePausePlay: + return remoteCommandCenter.togglePlayPauseCommand + case .nextTrack: + return remoteCommandCenter.nextTrackCommand + case .previousTrack: + return remoteCommandCenter.previousTrackCommand + case .changeRepeatMode: + return remoteCommandCenter.changeRepeatModeCommand + case .changeShuffleMode: + return remoteCommandCenter.changeShuffleModeCommand + case .changePlaybackRate: + return remoteCommandCenter.changePlaybackRateCommand + case .seekBackward: + return remoteCommandCenter.seekBackwardCommand + case .seekForward: + return remoteCommandCenter.seekForwardCommand + case .skipBackward: + return remoteCommandCenter.skipBackwardCommand + case .skipForward: + return remoteCommandCenter.skipForwardCommand + case .changePlaybackPosition: + return remoteCommandCenter.changePlaybackPositionCommand + case .rating: + return remoteCommandCenter.ratingCommand + case .like: + return remoteCommandCenter.likeCommand + case .dislike: + return remoteCommandCenter.dislikeCommand + case .bookmark: + return remoteCommandCenter.bookmarkCommand + case .enableLanguageOption: + return remoteCommandCenter.enableLanguageOptionCommand + case .disableLanguageOption: + return remoteCommandCenter.disableLanguageOptionCommand + } + } + + func removeHandler() { + remoteCommand.removeTarget(nil) + } + + func addHandler(_ handler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) { + + remoteCommand.removeTarget(nil) + + switch self { + case .skipBackward: + MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [15.0] + case .skipForward: + MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [15.0] + default: () + } + + remoteCommand.addTarget { handler(self, $0) } + } + + func isEnabled(_ isEnabled: Bool) { + remoteCommand.isEnabled = isEnabled + } +} diff --git a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableMetadata.swift b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableMetadata.swift new file mode 100644 index 00000000..13ffa9d7 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableMetadata.swift @@ -0,0 +1,65 @@ +// +// 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 Foundation +import MediaPlayer + +struct NowPlayableStaticMetadata { + + let mediaType: MPNowPlayingInfoMediaType + let isLiveStream: Bool + + let title: String + let artist: String? + let artwork: MPMediaItemArtwork? + + let albumArtist: String? + let albumTitle: String? + + init( + mediaType: MPNowPlayingInfoMediaType, + isLiveStream: Bool = false, + title: String, + artist: String? = nil, + artwork: MPMediaItemArtwork? = nil, + albumArtist: String? = nil, + albumTitle: String? = nil + ) { + self.mediaType = mediaType + self.isLiveStream = isLiveStream + self.title = title + self.artist = artist + self.artwork = artwork + self.albumArtist = albumArtist + self.albumTitle = albumTitle + } +} + +struct NowPlayableDynamicMetadata { + + let rate: Float + let position: Duration + let duration: Duration + + let currentLanguageOptions: [MPNowPlayingInfoLanguageOption] + let availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup] + + init( + rate: Float = 1, + position: Duration, + duration: Duration, + currentLanguageOptions: [MPNowPlayingInfoLanguageOption] = [], + availableLanguageOptionGroups: [MPNowPlayingInfoLanguageOptionGroup] = [] + ) { + self.rate = rate + self.position = position + self.duration = duration + self.currentLanguageOptions = currentLanguageOptions + self.availableLanguageOptionGroups = availableLanguageOptionGroups + } +} diff --git a/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift new file mode 100644 index 00000000..05e24e5e --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/NowPlayable/NowPlayableObserver.swift @@ -0,0 +1,305 @@ +// +// 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 Combine +import Foundation +import Logging +import MediaPlayer +import Nuke + +// TODO: ensure proper state handling +// - manager states +// - playback request states +// TODO: have MediaPlayerItem report supported commands + +@MainActor +class NowPlayableObserver: ViewModel, MediaPlayerObserver { + + private var defaultRegisteredCommands: [NowPlayableCommand] { + [ + .play, + .pause, + .togglePausePlay, + .skipBackward, + .skipForward, + .changePlaybackPosition, + // TODO: only register next/previous if there is a queue +// .nextTrack, +// .previousTrack, + ] + } + + private var itemImageCancellable: AnyCancellable? + private var playbackRequestStateBeforeInterruption: MediaPlayerManager.PlaybackRequestStatus = .playing + + weak var manager: MediaPlayerManager? { + willSet { + guard let newValue else { return } + setup(with: newValue) + } + } + + private func setup(with manager: MediaPlayerManager) { + do { + try startSession() + } catch { + logger.critical("Unable to activate audio session: \(error.localizedDescription)") + } + + cancellables = [] + + manager.actions + .sink { [weak self] newValue in self?.actionDidChange(newValue) } + .store(in: &cancellables) + + manager.$playbackItem + .sink { [weak self] newValue in self?.playbackItemDidChange(newValue) } + .store(in: &cancellables) + + manager.$playbackRequestStatus + .sink { [weak self] newValue in self?.playbackRequestStatusDidChange(newValue) } + .store(in: &cancellables) + + manager.secondsBox.$value + .sink { [weak self] newValue in self?.secondsDidChange(newValue) } + .store(in: &cancellables) + + Notifications[.avAudioSessionInterruption] + .publisher + .sink { i in + Task { @MainActor in + self.handleInterruption(type: i.0, options: i.1) + } + } + .store(in: &cancellables) + + Task { @MainActor in + configureRemoteCommands( + defaultRegisteredCommands, + commandHandler: handleCommand + ) + } + } + + private func playbackRequestStatusDidChange(_ newStatus: MediaPlayerManager.PlaybackRequestStatus) { + handleNowPlayablePlaybackChange( + playing: newStatus == .playing, + metadata: .init( + position: manager?.seconds ?? .zero, + duration: manager?.item.runtime ?? .zero + ) + ) + } + + private func secondsDidChange(_ newSeconds: Duration) { + handleNowPlayablePlaybackChange( + playing: true, + metadata: .init( + position: newSeconds, + duration: manager?.item.runtime ?? .zero + ) + ) + } + + private func actionDidChange(_ newAction: MediaPlayerManager._Action) { + switch newAction { + case .stop, .error: + handleStopAction() + default: () + } + } + + // TODO: remove and respond to manager action publisher instead + // TODO: register different commands based on item capabilities + private func playbackItemDidChange(_ newItem: MediaPlayerItem?) { + itemImageCancellable?.cancel() + itemImageCancellable = nil + guard let newItem else { return } + + setNowPlayingMetadata(newItem.baseItem.nowPlayableStaticMetadata()) + + itemImageCancellable = Task { + let currentBaseItem = newItem.baseItem + guard let image = await newItem.thumbnailProvider?() else { return } + guard manager?.item.id == currentBaseItem.id else { return } + + await MainActor.run { + setNowPlayingMetadata( + currentBaseItem.nowPlayableStaticMetadata(image) + ) + } + } + .asAnyCancellable() + + handleNowPlayablePlaybackChange( + playing: true, + metadata: .init( + position: manager?.seconds ?? .zero, + duration: manager?.item.runtime ?? .zero + ) + ) + } + + private func handleStopAction() { + cancellables = [] + + for command in defaultRegisteredCommands { + command.removeHandler() + } + + Task(priority: .userInitiated) { + // TODO: figure out way to not need delay + // Delay to wait for io to stop + try? await Task.sleep(for: .seconds(0.3)) + + do { + try stopSession() + } catch { + logger.critical("Unable to stop audio session: \(error.localizedDescription)") + } + } + } + + // TODO: complete by referencing apple code + // - restart + @MainActor + private func handleInterruption( + type: AVAudioSession.InterruptionType, + options: AVAudioSession.InterruptionOptions + ) { + switch type { + case .began: + playbackRequestStateBeforeInterruption = manager?.playbackRequestStatus ?? .playing + manager?.setPlaybackRequestStatus(status: .paused) + case .ended: + do { + try startSession() + + if playbackRequestStateBeforeInterruption == .playing { + if options.contains(.shouldResume) { + manager?.setPlaybackRequestStatus(status: .playing) + } else { + manager?.setPlaybackRequestStatus(status: .paused) + } + } + } catch { + logger.critical("Unable to reactivate audio session after interruption: \(error.localizedDescription)") + manager?.stop() + } + @unknown default: () + } + } + + @MainActor + private func handleCommand( + command: NowPlayableCommand, + event: MPRemoteCommandEvent + ) -> MPRemoteCommandHandlerStatus { + switch command { + case .pause: + manager?.setPlaybackRequestStatus(status: .paused) + case .play: + manager?.setPlaybackRequestStatus(status: .playing) + case .togglePausePlay: + manager?.togglePlayPause() + case .skipBackward: + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + manager?.proxy?.jumpBackward(.seconds(event.interval)) + case .skipForward: + guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } + manager?.proxy?.jumpForward(.seconds(event.interval)) + case .changePlaybackPosition: + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + manager?.proxy?.setSeconds(Duration.seconds(event.positionTime)) + case .nextTrack: + guard let nextItem = manager?.queue?.nextItem else { return .commandFailed } + manager?.playNewItem(provider: nextItem) + case .previousTrack: + guard let previousItem = manager?.queue?.previousItem else { return .commandFailed } + manager?.playNewItem(provider: previousItem) + default: () + } + + return .success + } + + private func handleNowPlayablePlaybackChange( + playing: Bool, + metadata: NowPlayableDynamicMetadata + ) { + setNowPlayingPlaybackInfo(metadata) + MPNowPlayingInfoCenter.default().playbackState = playing ? .playing : .paused + } + + private func configureRemoteCommands( + _ commands: [NowPlayableCommand], + commandHandler: @escaping (NowPlayableCommand, MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus + ) { + guard commands.isNotEmpty else { return } + + for command in commands { + command.addHandler(commandHandler) + command.isEnabled(true) + } + } + + private func setNowPlayingMetadata(_ metadata: NowPlayableStaticMetadata) { + + let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + var nowPlayingInfo: [String: Any] = [:] + + nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = metadata.mediaType.rawValue + nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = metadata.isLiveStream + nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title + nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.artist + nowPlayingInfo[MPMediaItemPropertyArtwork] = metadata.artwork + nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = metadata.albumArtist + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata.albumTitle + + nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo + } + + private func setNowPlayingPlaybackInfo(_ metadata: NowPlayableDynamicMetadata) { + + let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + var nowPlayingInfo: [String: Any] = nowPlayingInfoCenter.nowPlayingInfo ?? [:] + + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = Float(metadata.duration.seconds) + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Float(metadata.position.seconds) + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = metadata.rate + nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 + nowPlayingInfo[MPNowPlayingInfoPropertyCurrentLanguageOptions] = metadata.currentLanguageOptions + nowPlayingInfo[MPNowPlayingInfoPropertyAvailableLanguageOptions] = metadata.availableLanguageOptionGroups + + nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo + } + + private func startSession() throws { + + let audioSession = AVAudioSession.sharedInstance() + + do { + try audioSession.setCategory(.playback, mode: .default) + try audioSession.setActive(true) + logger.trace("Started AVAudioSession") + } catch { + logger.critical("Unable to activate AVAudioSession instance: \(error.localizedDescription)") + throw error + } + } + + private func stopSession() throws { + do { + try AVAudioSession.sharedInstance().setActive(false) + logger.trace("Stopped AVAudioSession") + } catch { + logger.critical("Unable to deactivate AVAudioSession instance: \(error.localizedDescription)") + throw error + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/PreviewImageProvider/ChapterPreviewImageProvider.swift b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/ChapterPreviewImageProvider.swift new file mode 100644 index 00000000..f36e5823 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/ChapterPreviewImageProvider.swift @@ -0,0 +1,70 @@ +// +// 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 Get +import JellyfinAPI +import UIKit + +// TODO: preload chapter images +// - somehow tell player if there are no images +// and don't present popup overlay +// TODO: just use Nuke image pipeline + +class ChapterPreviewImageProvider: PreviewImageProvider { + + let chapters: [ChapterInfo.FullInfo] + + @MainActor + private var images: [Int: UIImage] = [:] + @MainActor + private var imageTasks: [Int: Task] = [:] + + init(chapters: [ChapterInfo.FullInfo]) { + self.chapters = chapters + } + + func imageIndex(for seconds: Duration) -> Int? { + guard let currentChapterIndex = chapters + .firstIndex(where: { + guard let startSeconds = $0.chapterInfo.startSeconds else { return false } + return startSeconds > seconds + } + ) else { return nil } + + return max(0, currentChapterIndex - 1) + } + + @MainActor + func image(for seconds: Duration) async -> UIImage? { + guard let chapterIndex = imageIndex(for: seconds) else { return nil } + + if let image = images[chapterIndex] { + return image + } + + if let task = imageTasks[chapterIndex] { + return await task.value + } + + let newTask = Task { + let client = Container.shared.currentUserSession()!.client + + guard let chapterInfo = chapters[safe: chapterIndex], let imageUrl = chapterInfo.imageSource.url else { return nil } + let request: Request = .init(url: imageUrl) + + guard let response = try? await client.send(request) else { return nil } + guard let image = UIImage(data: response.value) else { return nil } + + return image + } + + imageTasks[chapterIndex] = newTask + return await newTask.value + } +} diff --git a/Shared/Objects/MediaPlayerManager/PreviewImageProvider/PreviewImageProvider.swift b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/PreviewImageProvider.swift new file mode 100644 index 00000000..9778628b --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/PreviewImageProvider.swift @@ -0,0 +1,15 @@ +// +// 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 Combine +import UIKit + +protocol PreviewImageProvider: ObservableObject { + func image(for seconds: Duration) async -> UIImage? + func imageIndex(for seconds: Duration) -> Int? +} diff --git a/Shared/Objects/MediaPlayerManager/PreviewImageProvider/TrickplayPreviewImageProvider.swift b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/TrickplayPreviewImageProvider.swift new file mode 100644 index 00000000..46bed83b --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/PreviewImageProvider/TrickplayPreviewImageProvider.swift @@ -0,0 +1,152 @@ +// +// 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 UIKit + +// TODO: preload adjacent images +// TODO: don't just select first trickplayinfo + +class TrickplayPreviewImageProvider: PreviewImageProvider { + + private struct TrickplayImage { + + let image: UIImage + let secondsRange: ClosedRange + + let columns: Int + let rows: Int + let tileInterval: Duration + + func tile(for seconds: Duration) -> UIImage? { + guard secondsRange.contains(seconds) else { + return nil + } + + let index = Int(((seconds - secondsRange.lowerBound) / tileInterval).rounded(.down)) + let tileImage = image.getTileImage(columns: columns, rows: rows, index: index) + return tileImage + } + } + + private let info: TrickplayInfo + private let itemID: String + private let mediaSourceID: String + private let runtime: Duration + + @MainActor + private var imageTasks: [Int: Task] = [:] + + init( + info: TrickplayInfo, + itemID: String, + mediaSourceID: String, + runtime: Duration + ) { + self.info = info + self.itemID = itemID + self.mediaSourceID = mediaSourceID + self.runtime = runtime + } + + func imageIndex(for seconds: Duration) -> Int? { + let intervalIndex = Int(seconds / Duration.milliseconds(info.interval ?? 1000)) + return intervalIndex + } + + @MainActor + func image(for seconds: Duration) async -> UIImage? { + let rows = info.tileHeight ?? 0 + let columns = info.tileWidth ?? 0 + let area = rows * columns + let intervalIndex = Int(seconds / Duration.milliseconds(info.interval ?? 1000)) + let imageIndex = intervalIndex / area + + if let task = imageTasks[imageIndex] { + guard let image = await task.value else { return nil } + return image.tile(for: seconds) + } + + let interval = info.interval ?? 0 + let tileImageDuration = Duration.milliseconds( + Double(interval * rows * columns) + ) + let tileInterval = Duration.milliseconds(interval) + + let currentImageTask = task( + imageIndex: imageIndex, + tileImageDuration: tileImageDuration, + columns: columns, + rows: rows, + tileInterval: tileInterval + ) + + if imageIndex > 1, !imageTasks.keys.contains(imageIndex - 1) { + let previousIndexTask = task( + imageIndex: imageIndex - 1, + tileImageDuration: tileImageDuration, + columns: columns, + rows: rows, + tileInterval: tileInterval + ) + imageTasks[imageIndex - 1] = previousIndexTask + } + + if seconds < (runtime - tileImageDuration), !imageTasks.keys.contains(imageIndex + 1) { + let nextIndexTask = task( + imageIndex: imageIndex + 1, + tileImageDuration: tileImageDuration, + columns: columns, + rows: rows, + tileInterval: tileInterval + ) + imageTasks[imageIndex + 1] = nextIndexTask + } + + imageTasks[imageIndex] = currentImageTask + + guard let image = await currentImageTask.value else { return nil } + return image.tile(for: seconds) + } + + private func task( + imageIndex: Int, + tileImageDuration: Duration, + columns: Int, + rows: Int, + tileInterval: Duration + ) -> Task { + Task { [weak self] () -> TrickplayImage? in + guard let tileWidth = self?.info.width else { return nil } + guard let itemID = self?.itemID else { return nil } + + let client = Container.shared.currentUserSession()!.client + let request = Paths.getTrickplayTileImage( + itemID: itemID, + width: tileWidth, + index: imageIndex + ) + guard let response = try? await client.send(request) else { return nil } + guard let image = UIImage(data: response.value) else { return nil } + + let secondsRangeStart = tileImageDuration * Double(imageIndex) + let secondsRangeEnd = secondsRangeStart + tileImageDuration + + let trickplayImage = TrickplayImage( + image: image, + secondsRange: secondsRangeStart ... secondsRangeEnd, + columns: columns, + rows: rows, + tileInterval: tileInterval + ) + + return trickplayImage + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift new file mode 100644 index 00000000..277e329d --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift @@ -0,0 +1,547 @@ +// +// 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 CollectionHStack +import CollectionVGrid +import Combine +import Defaults +import Foundation +import IdentifiedCollections +import JellyfinAPI +import SwiftUI + +// TODO: loading, error states +// TODO: watched/status indicators +// TODO: sometimes safe area for CollectionHStack doesn't trigger + +@MainActor +class EpisodeMediaPlayerQueue: ViewModel, MediaPlayerQueue { + + weak var manager: MediaPlayerManager? { + didSet { + cancellables = [] + guard let manager else { return } + manager.$playbackItem + .sink { [weak self] newItem in + self?.didReceive(newItem: newItem) + } + .store(in: &cancellables) + } + } + + let displayTitle: String = L10n.episodes + let id: String = "EpisodeMediaPlayerQueue" + + @Published + var nextItem: MediaPlayerItemProvider? = nil + @Published + var previousItem: MediaPlayerItemProvider? = nil + + @Published + var hasNextItem: Bool = false + @Published + var hasPreviousItem: Bool = false + + lazy var hasNextItemPublisher: Published.Publisher = $hasNextItem + lazy var hasPreviousItemPublisher: Published.Publisher = $hasPreviousItem + lazy var nextItemPublisher: Published.Publisher = $nextItem + lazy var previousItemPublisher: Published.Publisher = $previousItem + + private var currentAdjacentEpisodesTask: AnyCancellable? + private let seriesViewModel: SeriesItemViewModel + + init(episode: BaseItemDto) { + self.seriesViewModel = SeriesItemViewModel(episode: episode) + super.init() + + seriesViewModel.send(.refresh) + } + + var videoPlayerBody: some PlatformView { + EpisodeOverlay(viewModel: seriesViewModel) + } + + private func didReceive(newItem: MediaPlayerItem?) { + self.currentAdjacentEpisodesTask = Task { + await MainActor.run { + self.nextItem = nil + self.previousItem = nil + self.hasNextItem = false + self.hasPreviousItem = false + } + + try await self.getAdjacentEpisodes(for: newItem?.baseItem) + } + .asAnyCancellable() + } + + private func getAdjacentEpisodes(for item: BaseItemDto?) async throws { + guard let item else { return } + guard let seriesID = item.seriesID, item.type == .episode else { return } + + let parameters = Paths.GetEpisodesParameters( + userID: userSession.user.id, + fields: .MinimumFields, + adjacentTo: item.id!, + limit: 3 + ) + let request = Paths.getEpisodes(seriesID: seriesID, parameters: parameters) + let response = try await userSession.client.send(request) + + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle + + // 1 + guard let items = response.value.items, items.count > 1 else { return } + + var previousItem: BaseItemDto? + var nextItem: BaseItemDto? + + if items.count == 2 { + if items[0].id == item.id { + // 2 + nextItem = items[1] + + } else { + // 3 + previousItem = items[0] + } + } else { + nextItem = items[2] + previousItem = items[0] + } + + var nextProvider: MediaPlayerItemProvider? + var previousProvider: MediaPlayerItemProvider? + + if let nextItem { + nextProvider = MediaPlayerItemProvider(item: nextItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + } + + if let previousItem { + previousProvider = MediaPlayerItemProvider(item: previousItem) { item in + try await MediaPlayerItem.build(for: item) { + $0.userData?.playbackPositionTicks = .zero + } + } + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.nextItem = nextProvider + self.previousItem = previousProvider + self.hasNextItem = nextProvider != nil + self.hasPreviousItem = previousProvider != nil + } + } +} + +extension EpisodeMediaPlayerQueue { + + private struct EpisodeOverlay: PlatformView { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + var viewModel: SeriesItemViewModel + + @State + private var selection: SeasonItemViewModel.ID? + + private var selectionViewModel: SeasonItemViewModel? { + guard let selection else { return nil } + return viewModel.seasons[id: selection] + } + + private func select(episode: BaseItemDto) { + let provider = MediaPlayerItemProvider(item: episode) { item in + let mediaSource = item.mediaSources?.first + + return try await MediaPlayerItem.build( + for: item, + mediaSource: mediaSource! + ) + } + + manager.playNewItem(provider: provider) + } + + var tvOSView: some View { EmptyView() } + + var iOSView: some View { + CompactOrRegularView( + isCompact: containerState.isCompact + ) { + CompactSeasonStackObserver( + selection: $selection, + action: select + ) + } regularView: { + RegularSeasonStackObserver( + selection: $selection, + action: select + ) + } + .environmentObject(viewModel) + .onAppear { + if let seasonID = manager.item.seasonID, let season = viewModel.seasons[id: seasonID] { + if season.elements.isEmpty { + season.send(.refresh) + } + selection = season.id + } else { + selection = viewModel.seasons.first?.id + } + } + } + } + + private struct CompactSeasonStackObserver: View { + + @EnvironmentObject + private var seriesViewModel: SeriesItemViewModel + + private let selection: Binding + private let action: (BaseItemDto) -> Void + + private var selectionViewModel: SeasonItemViewModel? { + guard let id = selection.wrappedValue else { return nil } + return seriesViewModel.seasons[id: id] + } + + init( + selection: Binding, + action: @escaping (BaseItemDto) -> Void + ) { + self.selection = selection + self.action = action + } + + private struct _Body: View { + + @ObservedObject + var selectionViewModel: SeasonItemViewModel + + let action: (BaseItemDto) -> Void + + var body: some View { + CollectionVGrid( + uniqueElements: selectionViewModel.elements, + layout: .columns( + 1, + insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0) + ) + ) { item in + EpisodeRow(episode: item) { + action(item) + } + .edgePadding(.horizontal) + } + } + } + + var body: some View { + if let selectionViewModel { + _Body( + selectionViewModel: selectionViewModel, + action: action + ) + } + } + } + + private struct RegularSeasonStackObserver: View { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @EnvironmentObject + private var seriesViewModel: SeriesItemViewModel + + private let selection: Binding + private let action: (BaseItemDto) -> Void + + private var selectionViewModel: SeasonItemViewModel? { + guard let id = selection.wrappedValue else { return nil } + return seriesViewModel.seasons[id: id] + } + + init( + selection: Binding, + action: @escaping (BaseItemDto) -> Void + ) { + self.selection = selection + self.action = action + } + + private struct _Body: View { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @ObservedObject + var selectionViewModel: SeasonItemViewModel + + let action: (BaseItemDto) -> Void + + var body: some View { + CollectionHStack( + uniqueElements: selectionViewModel.elements, + id: \.unwrappedIDHashOrZero + ) { item in + EpisodeButton(episode: item) { + action(item) + } + .frame(height: 150) + } + .insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding) + } + } + + var body: some View { + if let selectionViewModel { + _Body( + selectionViewModel: selectionViewModel, + action: action + ) + .frame(height: 150) + } + } + + // TODO: make experimental setting to enable + private struct _ButtonStack: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var seriesViewModel: SeriesItemViewModel + + let selection: Binding + let selectionViewModel: SeasonItemViewModel + + init( + selection: Binding, + selectionViewModel: SeasonItemViewModel + ) { + self.selection = selection + self.selectionViewModel = selectionViewModel + } + + var body: some View { + VStack { + Menu { + ForEach(seriesViewModel.seasons, id: \.season.id) { season in + Button { + selection.wrappedValue = season.id + if season.elements.isEmpty { + season.send(.refresh) + } + } label: { + if season.id == selection.wrappedValue { + Label(season.season.displayTitle, systemImage: "checkmark") + } else { + Text(season.season.displayTitle) + } + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 7) + .foregroundStyle(.white) + + Label(selectionViewModel.season.displayTitle, systemImage: "chevron.down") + .fontWeight(.semibold) + .foregroundStyle(.black) + } + } + .frame(maxHeight: .infinity) + + Button { + guard let nextItem = manager.queue?.nextItem else { return } + manager.playNewItem(provider: nextItem) + manager.setPlaybackRequestStatus(status: .playing) + containerState.select(supplement: nil) + } label: { + ZStack { + RoundedRectangle(cornerRadius: 7) + .foregroundStyle(.white) + + Label("Next", systemImage: "forward.end.fill") + .fontWeight(.semibold) + .foregroundStyle(.black) + } + } + .frame(maxHeight: .infinity) + + Button { + guard let previousItem = manager.queue?.previousItem else { return } + manager.playNewItem(provider: previousItem) + manager.setPlaybackRequestStatus(status: .playing) + containerState.select(supplement: nil) + } label: { + ZStack { + RoundedRectangle(cornerRadius: 7) + .foregroundStyle(.white) + + Label("Previous", systemImage: "backward.end.fill") + .fontWeight(.semibold) + .foregroundStyle(.black) + } + } + .frame(maxHeight: .infinity) + } + .frame(width: 150) + .edgePadding(.horizontal) +// .padding(.trailing, safeAreaInsets.trailing) + } + } + } + + private struct EpisodePreview: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isSelected) + private var isSelected: Bool + + let episode: BaseItemDto + + var body: some View { + ZStack { + Rectangle() + .fill(.complexSecondary) + + ImageView(episode.imageSource(.primary, maxWidth: 200)) + .failure { + SystemImageContentView(systemName: episode.systemImage) + } + } + .overlay { + if isSelected { + ContainerRelativeShape() + .stroke( + accentColor, + lineWidth: 8 + ) + .clipped() + } + } + .posterStyle(.landscape) + } + } + + private struct EpisodeDescription: View { + + let episode: BaseItemDto + + var body: some View { + DotHStack { + if let seasonEpisodeLabel = episode.seasonEpisodeLabel { + Text(seasonEpisodeLabel) + } + + if let runtime = episode.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private struct EpisodeRow: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var manager: MediaPlayerManager + + let episode: BaseItemDto + let action: () -> Void + + private var isCurrentEpisode: Bool { + manager.item.id == episode.id + } + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + EpisodePreview(episode: episode) + .frame(width: 110) + .padding(.vertical, 8) + } content: { + VStack(alignment: .leading, spacing: 5) { + Text(episode.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + EpisodeDescription(episode: episode) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onSelect(perform: action) + .isSelected(isCurrentEpisode) + } + } + + private struct EpisodeButton: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var manager: MediaPlayerManager + + let episode: BaseItemDto + let action: () -> Void + + private var isCurrentEpisode: Bool { + manager.item.id == episode.id + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 5) { + EpisodePreview(episode: episode) + + VStack(alignment: .leading, spacing: 5) { + Text(episode.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundStyle(.primary) + .frame(height: 15) + + EpisodeDescription(episode: episode) + .frame(height: 20, alignment: .top) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .foregroundStyle(.primary, .secondary) + .isSelected(isCurrentEpisode) + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift new file mode 100644 index 00000000..d720f983 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift @@ -0,0 +1,275 @@ +// +// 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 CollectionHStack +import CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: current button +// TODO: scroll to current chapter on appear +// TODO: fix swapping between chapters on selection +// - little flicker at seconds boundary +// TODO: sometimes safe area for CollectionHStack doesn't trigger +// TODO: fix chapter image aspect fit +// - still be in a 1.77 box + +class MediaChaptersSupplement: ObservableObject, MediaPlayerSupplement { + + let chapters: [ChapterInfo.FullInfo] + let displayTitle: String = L10n.chapters + let id: String + + init(chapters: [ChapterInfo.FullInfo]) { + self.chapters = chapters + self.id = "Chapters-\(chapters.hashValue)" + } + + func isCurrentChapter(seconds: Duration, chapter: ChapterInfo.FullInfo) -> Bool { + guard let currentChapterIndex = chapters + .firstIndex(where: { + guard let startSeconds = $0.chapterInfo.startSeconds else { return false } + return startSeconds > seconds + } + ) else { return false } + + guard let currentChapter = chapters[safe: max(0, currentChapterIndex - 1)] else { return false } + return currentChapter.id == chapter.id + } + + var videoPlayerBody: some PlatformView { + ChapterOverlay(supplement: self) + } +} + +extension MediaChaptersSupplement { + + private struct ChapterOverlay: PlatformView { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + private var supplement: MediaChaptersSupplement + + @StateObject + private var collectionHStackProxy: CollectionHStackProxy = .init() + + init(supplement: MediaChaptersSupplement) { + self.supplement = supplement + } + + private var chapters: [ChapterInfo.FullInfo] { + supplement.chapters + } + + private var currentChapter: ChapterInfo.FullInfo? { + chapters.first( + where: { + guard let startSeconds = $0.chapterInfo.startSeconds else { return false } + return startSeconds <= manager.seconds + } + ) + } + + var iOSView: some View { + CompactOrRegularView( + isCompact: containerState.isCompact + ) { + iOSCompactView + } regularView: { + iOSRegularView + } + } + + @ViewBuilder + private var iOSCompactView: some View { + // TODO: scroll to current chapter + CollectionVGrid( + uniqueElements: chapters, + layout: .columns( + 1, + insets: .init(top: 0, leading: 0, bottom: EdgeInsets.edgePadding, trailing: 0) + ) + ) { chapter, _ in + ChapterRow(chapter: chapter) { + guard let startSeconds = chapter.chapterInfo.startSeconds else { return } + manager.proxy?.setSeconds(startSeconds) + manager.setPlaybackRequestStatus(status: .playing) + } + .edgePadding(.horizontal) + .environmentObject(supplement) + } + } + + @ViewBuilder + private var iOSRegularView: some View { + // TODO: change to continuousLeadingEdge after + // layout inset fix in CollectionHStack + CollectionHStack( + uniqueElements: chapters + ) { chapter in + ChapterButton(chapter: chapter) { + guard let startSeconds = chapter.chapterInfo.startSeconds else { return } + manager.proxy?.setSeconds(startSeconds) + manager.setPlaybackRequestStatus(status: .playing) + } + .frame(height: 150) + .environmentObject(supplement) + } + .insets(horizontal: max(safeAreaInsets.leading, safeAreaInsets.trailing) + EdgeInsets.edgePadding) + .proxy(collectionHStackProxy) + .frame(height: 150) + .onAppear { + guard let currentChapter else { return } + collectionHStackProxy.scrollTo(id: currentChapter.id) + } + } + + var tvOSView: some View { EmptyView() } + } + + struct ChapterPreview: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isSelected) + private var isSelected + + let chapter: ChapterInfo.FullInfo + + var body: some View { + PosterImage( + item: chapter, + type: .landscape, + contentMode: .fill + ) + .overlay { + if isSelected { + ContainerRelativeShape() + .stroke( + accentColor, + lineWidth: 8 + ) + .clipped() + } + } + .posterStyle(.landscape) + } + } + + struct ChapterContent: View { + + let chapter: ChapterInfo.FullInfo + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(chapter.chapterInfo.displayTitle) + .lineLimit(1) + .foregroundStyle(.white) + .frame(height: 15) + + Text(chapter.chapterInfo.startSeconds ?? .zero, format: .runtime) + .frame(height: 20) + .foregroundStyle(Color(UIColor.systemBlue)) + .padding(.horizontal, 4) + .background { + Color(.darkGray) + .opacity(0.2) + .cornerRadius(4) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .font(.subheadline) + .fontWeight(.semibold) + } + } + + struct ChapterRow: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var supplement: MediaChaptersSupplement + + @State + private var activeSeconds: Duration = .zero + + let chapter: ChapterInfo.FullInfo + let action: () -> Void + + private var isCurrentChapter: Bool { + supplement.isCurrentChapter( + seconds: activeSeconds, + chapter: chapter + ) + } + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ChapterPreview( + chapter: chapter + ) + .frame(width: 110) + .padding(.vertical, 8) + } content: { + ChapterContent(chapter: chapter) + } + .onSelect(perform: action) + .assign(manager.secondsBox.$value, to: $activeSeconds) + .isSelected(isCurrentChapter) + } + } + + struct ChapterButton: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var supplement: MediaChaptersSupplement + + @State + private var activeSeconds: Duration = .zero + + let chapter: ChapterInfo.FullInfo + let action: () -> Void + + private var isCurrentChapter: Bool { + supplement.isCurrentChapter( + seconds: activeSeconds, + chapter: chapter + ) + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 5) { + ChapterPreview( + chapter: chapter + ) + + ChapterContent( + chapter: chapter + ) + } + .font(.subheadline) + .fontWeight(.semibold) + } + .foregroundStyle(.primary, .secondary) + .assign(manager.secondsBox.$value, to: $activeSeconds) + .isSelected(isCurrentChapter) + } + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift new file mode 100644 index 00000000..2eea836f --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift @@ -0,0 +1,177 @@ +// +// 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 + +// TODO: scroll if description too long + +struct MediaInfoSupplement: MediaPlayerSupplement { + + let displayTitle: String = "Info" + let item: BaseItemDto + + var id: String { + "MediaInfo-\(item.id ?? "any")" + } + + var videoPlayerBody: some PlatformView { + InfoOverlay(item: item) + } +} + +extension MediaInfoSupplement { + + private struct InfoOverlay: PlatformView { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets: EdgeInsets + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + let item: BaseItemDto + + @ViewBuilder + private var accessoryView: some View { + DotHStack { + if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { + Text(seasonEpisodeLocator) + } else if let premiereYear = item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = item.runTimeLabel { + Text(runtime) + } + + if let officialRating = item.officialRating { + Text(officialRating) + } + } + } + + @ViewBuilder + private var fromBeginningButton: some View { + Button("From Beginning", systemImage: "play.fill") { + manager.proxy?.setSeconds(.zero) + manager.setPlaybackRequestStatus(status: .playing) + containerState.select(supplement: nil) + } + #if os(iOS) + .buttonStyle(.material) + #endif + .frame(width: 200, height: 50) + .font(.subheadline) + .fontWeight(.semibold) + } + + // TODO: may need to be a layout for correct overview frame + // with scrolling if too long + var iOSView: some View { + CompactOrRegularView( + isCompact: containerState.isCompact + ) { + iOSCompactView + } regularView: { + iOSRegularView + } + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + .edgePadding(.horizontal) + .edgePadding(.bottom) + } + + @ViewBuilder + private var iOSCompactView: some View { + VStack(alignment: .leading) { + Group { + Text(item.displayTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if let overview = item.overview { + Text(overview) + .font(.subheadline) + .fontWeight(.regular) + } + + accessoryView + .font(.caption) + .foregroundStyle(.secondary) + } + .allowsHitTesting(false) + + if !item.isLiveStream { + Button { + manager.proxy?.setSeconds(.zero) + manager.setPlaybackRequestStatus(status: .playing) + containerState.select(supplement: nil) + } label: { + ZStack { + RoundedRectangle(cornerRadius: 7) + .foregroundStyle(.white) + + Label("From Beginning", systemImage: "play.fill") + .fontWeight(.semibold) + .foregroundStyle(.black) + } + } + .frame(maxWidth: .infinity) + .frame(height: 40) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private var iOSRegularView: some View { + HStack(alignment: .bottom, spacing: EdgeInsets.edgePadding) { + // TODO: determine what to do with non-portrait (channel, home video) images + // - use aspect ratio? + PosterImage( + item: item, + type: item.preferredPosterDisplayType, + contentMode: .fit + ) + .environment(\.isOverComplexContent, true) + + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(.callout) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if let overview = item.overview { + Text(overview) + .font(.subheadline) + .fontWeight(.regular) + .lineLimit(3) + } + + accessoryView + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if !item.isLiveStream { + VStack { + fromBeginningButton + } + } + } + } + + var tvOSView: some View { EmptyView() } + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueue.swift new file mode 100644 index 00000000..823d96c2 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerQueue.swift @@ -0,0 +1,91 @@ +// +// 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 Combine + +@MainActor +protocol MediaPlayerQueue: ObservableObject, MediaPlayerObserver, MediaPlayerSupplement { + + var hasNextItem: Bool { get } + var hasPreviousItem: Bool { get } + + var nextItem: MediaPlayerItemProvider? { get } + var previousItem: MediaPlayerItemProvider? { get } + + var hasNextItemPublisher: Published.Publisher { get set } + var hasPreviousItemPublisher: Published.Publisher { get set } + var nextItemPublisher: Published.Publisher { get set } + var previousItemPublisher: Published.Publisher { get set } +} + +extension MediaPlayerQueue { + + var hasNextItem: Bool { + nextItem != nil + } + + var hasPreviousItem: Bool { + previousItem != nil + } +} + +class AnyMediaPlayerQueue: MediaPlayerQueue { + + @Published + var hasNextItem: Bool + @Published + var hasPreviousItem: Bool + + @Published + var nextItem: MediaPlayerItemProvider? + @Published + var previousItem: MediaPlayerItemProvider? + + lazy var hasNextItemPublisher: Published.Publisher = $hasNextItem + lazy var hasPreviousItemPublisher: Published.Publisher = $hasPreviousItem + lazy var nextItemPublisher: Published.Publisher = $nextItem + lazy var previousItemPublisher: Published.Publisher = $previousItem + + private var wrapped: any MediaPlayerQueue + + var displayTitle: String { + wrapped.displayTitle + } + + var id: String { + wrapped.id + } + + weak var manager: MediaPlayerManager? { + get { wrapped.manager } + set { wrapped.manager = newValue } + } + + private var cancellables: [AnyCancellable] = [] + + init(_ wrapped: some MediaPlayerQueue) { + self.wrapped = wrapped + self.hasNextItem = wrapped.hasNextItem + self.hasPreviousItem = wrapped.hasPreviousItem + + wrapped.hasNextItemPublisher + .assign(to: &$hasNextItem) + wrapped.hasPreviousItemPublisher + .assign(to: &$hasPreviousItem) + wrapped.nextItemPublisher + .assign(to: &$nextItem) + wrapped.previousItemPublisher + .assign(to: &$previousItem) + } + + var videoPlayerBody: some PlatformView { + wrapped + .videoPlayerBody + .eraseToAnyView() + } +} diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerSupplement.swift new file mode 100644 index 00000000..90213412 --- /dev/null +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaPlayerSupplement.swift @@ -0,0 +1,51 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +// TODO: fullscreen supplement styles + +@MainActor +protocol MediaPlayerSupplement: Displayable, Identifiable { + + associatedtype VideoPlayerBody: PlatformView + + var id: String { get } + + @MainActor + @ViewBuilder + var videoPlayerBody: Self.VideoPlayerBody { get } +} + +struct AnyMediaPlayerSupplement: MediaPlayerSupplement, Equatable { + + let supplement: any MediaPlayerSupplement + + var displayTitle: String { + supplement.displayTitle + } + + var id: String { + supplement.id + } + + var videoPlayerBody: some PlatformView { + supplement.videoPlayerBody + .eraseToAnyView() + } + + init(_ supplement: any MediaPlayerSupplement) { + self.supplement = supplement + } + + static func == (lhs: AnyMediaPlayerSupplement, rhs: AnyMediaPlayerSupplement) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Shared/Objects/ObservedPublisher.swift b/Shared/Objects/ObservedPublisher.swift new file mode 100644 index 00000000..4d33f1b1 --- /dev/null +++ b/Shared/Objects/ObservedPublisher.swift @@ -0,0 +1,56 @@ +// +// 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 Combine +import Foundation + +/// Observable object property wrapper that allows observing +/// another `Publisher`. +@propertyWrapper +final class ObservedPublisher: ObservableObject { + + @Published + private(set) var wrappedValue: Value + + var projectedValue: AnyPublisher { + $wrappedValue + .eraseToAnyPublisher() + } + + private var cancellables = Set() + + init( + wrappedValue: Value, + observing publisher: P + ) where P.Output == Value, P.Failure == Never { + self.wrappedValue = wrappedValue + + publisher + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + self?.wrappedValue = newValue + } + .store(in: &cancellables) + } + + static subscript( + _enclosingInstance instance: T, + wrapped wrappedKeyPath: KeyPath, + storage storageKeyPath: KeyPath> + ) -> Value where T.ObjectWillChangePublisher == ObservableObjectPublisher { + let wrapper = instance[keyPath: storageKeyPath] + + wrapper.objectWillChange + .sink { [weak instance] _ in + instance?.objectWillChange.send() + } + .store(in: &wrapper.cancellables) + + return wrapper.wrappedValue + } +} diff --git a/Shared/Objects/PanAction.swift b/Shared/Objects/PanAction.swift new file mode 100644 index 00000000..26ecb5a5 --- /dev/null +++ b/Shared/Objects/PanAction.swift @@ -0,0 +1,42 @@ +// +// 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 EnvironmentValues { + + @Entry + var panAction: PanAction? = nil +} + +struct PanAction { + + let action: ( + _ translation: CGPoint, + _ velocity: CGPoint, + _ location: CGPoint, + _ unitPoint: UnitPoint, + _ state: UIGestureRecognizer.State + ) -> Void + + func callAsFunction( + translation: CGPoint, + velocity: CGPoint, + location: CGPoint, + unitPoint: UnitPoint, + state: UIGestureRecognizer.State + ) { + action( + translation, + velocity, + location, + unitPoint, + state + ) + } +} diff --git a/Shared/Objects/PinchAction.swift b/Shared/Objects/PinchAction.swift new file mode 100644 index 00000000..ea5c3c9a --- /dev/null +++ b/Shared/Objects/PinchAction.swift @@ -0,0 +1,32 @@ +// +// 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 EnvironmentValues { + + @Entry + var pinchAction: PinchAction? = nil +} + +struct PinchAction { + + let action: ( + _ scale: CGFloat, + _ velocity: CGFloat, + _ state: UIGestureRecognizer.State + ) -> Void + + func callAsFunction( + scale: CGFloat, + velocity: CGFloat, + state: UIGestureRecognizer.State + ) { + action(scale, velocity, state) + } +} diff --git a/Shared/Objects/PokeIntervalTimer.swift b/Shared/Objects/PokeIntervalTimer.swift new file mode 100644 index 00000000..cdcfeffe --- /dev/null +++ b/Shared/Objects/PokeIntervalTimer.swift @@ -0,0 +1,47 @@ +// +// 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 Combine +import Foundation + +class PokeIntervalTimer: ObservableObject, Publisher { + + typealias Output = Void + typealias Failure = Never + + private let defaultInterval: TimeInterval + private var delaySubject: PassthroughSubject = .init() + private var delayedWorkItem: DispatchWorkItem? + + init(defaultInterval: TimeInterval = 5) { + self.defaultInterval = defaultInterval + } + + func receive(subscriber: S) where S: Subscriber, S.Failure == Never, S.Input == Void { + delaySubject.receive(subscriber: subscriber) + } + + func poke(interval: TimeInterval? = nil) { + + let interval = interval ?? defaultInterval + + delayedWorkItem?.cancel() + + let newPollItem = DispatchWorkItem { + self.delaySubject.send(()) + } + + delayedWorkItem = newPollItem + + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: newPollItem) + } + + func stop() { + delayedWorkItem?.cancel() + } +} diff --git a/Shared/Objects/Poster/AnyPoster.swift b/Shared/Objects/Poster/AnyPoster.swift new file mode 100644 index 00000000..d3140011 --- /dev/null +++ b/Shared/Objects/Poster/AnyPoster.swift @@ -0,0 +1,79 @@ +// +// 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 Foundation +import SwiftUI + +struct AnyPoster: Poster { + + let _poster: any Poster + + init(_ poster: any Poster) { + self._poster = poster + } + + var preferredPosterDisplayType: PosterDisplayType { + _poster.preferredPosterDisplayType + } + + var displayTitle: String { + _poster.displayTitle + } + + var unwrappedIDHashOrZero: Int { + _poster.unwrappedIDHashOrZero + } + + var subtitle: String? { + _poster.subtitle + } + + var systemImage: String { + _poster.systemImage + } + + var id: Int { + AnyHashable(_poster).hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(_poster.unwrappedIDHashOrZero) + hasher.combine(_poster.displayTitle) + hasher.combine(_poster.subtitle) + hasher.combine(_poster.systemImage) + } + + var showTitle: Bool { + _poster.showTitle + } + + func portraitImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { + _poster.portraitImageSources(maxWidth: maxWidth, quality: quality) + } + + func landscapeImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { + _poster.landscapeImageSources(maxWidth: maxWidth, quality: quality) + } + + func cinematicImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { + _poster.cinematicImageSources(maxWidth: maxWidth, quality: quality) + } + + func squareImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { + _poster.squareImageSources(maxWidth: maxWidth, quality: quality) + } + + func transform(image: Image) -> some View { + _poster.transform(image: image) + .eraseToAnyView() + } + + static func == (lhs: AnyPoster, rhs: AnyPoster) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Shared/Objects/Poster/Poster.swift b/Shared/Objects/Poster/Poster.swift new file mode 100644 index 00000000..c8a1bc10 --- /dev/null +++ b/Shared/Objects/Poster/Poster.swift @@ -0,0 +1,100 @@ +// +// 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 Foundation +import SwiftUI + +// TODO: create environment for image sources +// - for when to have episode use series +// - pass in folder context +// - thumb +// - could remove cinematic, just use landscape + +/// A type that is displayed as a poster +protocol Poster: Displayable, Hashable, LibraryIdentifiable, SystemImageable { + + associatedtype ImageBody: View + + var preferredPosterDisplayType: PosterDisplayType { get } + + /// Optional subtitle when used as a poster + var subtitle: String? { get } + + /// Show the title + var showTitle: Bool { get } + + func portraitImageSources( + maxWidth: CGFloat?, + quality: Int? + ) -> [ImageSource] + + func landscapeImageSources( + maxWidth: CGFloat?, + quality: Int? + ) -> [ImageSource] + + func cinematicImageSources( + maxWidth: CGFloat?, + quality: Int? + ) -> [ImageSource] + + func squareImageSources( + maxWidth: CGFloat?, + quality: Int? + ) -> [ImageSource] + + func thumbImageSources() -> [ImageSource] + + @MainActor + @ViewBuilder + func transform(image: Image) -> ImageBody +} + +extension Poster { + + var subtitle: String? { + nil + } + + var showTitle: Bool { + true + } + + func portraitImageSources( + maxWidth: CGFloat? = nil, + quality: Int? = nil + ) -> [ImageSource] { + [] + } + + func landscapeImageSources( + maxWidth: CGFloat? = nil, + quality: Int? = nil + ) -> [ImageSource] { + [] + } + + func cinematicImageSources( + maxWidth: CGFloat?, + quality: Int? = nil + ) -> [ImageSource] { + [] + } + + func squareImageSources( + maxWidth: CGFloat?, + quality: Int? = nil + ) -> [ImageSource] { + [] + } + + // TODO: change to observe preferred poster display type + func thumbImageSources() -> [ImageSource] { + [] + } +} diff --git a/Shared/Objects/ToastProxy.swift b/Shared/Objects/ToastProxy.swift new file mode 100644 index 00000000..6725ec2d --- /dev/null +++ b/Shared/Objects/ToastProxy.swift @@ -0,0 +1,69 @@ +// +// 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 Combine +import SwiftUI + +@propertyWrapper +struct Toaster: DynamicProperty { + + @EnvironmentObject + private var toastProxy: ToastProxy + + var wrappedValue: ToastProxy { + toastProxy + } +} + +@MainActor +class ToastProxy: ObservableObject { + + @Published + var isPresenting: Bool = false + @Published + private(set) var systemName: String? = nil + @Published + private(set) var title: Text = Text("") +// @Published +// private(set) var messageID: String = "" + + private let pokeTimer = PokeIntervalTimer(defaultInterval: 2) + private var pokeCancellable: AnyCancellable? + + init() { + pokeCancellable = pokeTimer + .sink { + withAnimation { + self.isPresenting = false + } + } + } + + func present(_ title: String, systemName: String? = nil) { + present(Text(title), systemName: systemName) + } + + func present(_ title: Text, systemName: String? = nil) { + self.title = title + self.systemName = systemName + + poke(equalsPrevious: title == self.title) + } + + private func poke(equalsPrevious: Bool) { +// if equalsPrevious { +// messageID = UUID().uuidString +// } + + withAnimation(.spring) { + isPresenting = true + } + + pokeTimer.poke() + } +} diff --git a/Shared/Objects/VideoPlayerType/VideoPlayerType+jellypig.swift b/Shared/Objects/VideoPlayerType/VideoPlayerType+jellyflood.swift similarity index 100% rename from Shared/Objects/VideoPlayerType/VideoPlayerType+jellypig.swift rename to Shared/Objects/VideoPlayerType/VideoPlayerType+jellyflood.swift diff --git a/Shared/Objects/XtreamServer.swift b/Shared/Objects/XtreamServer.swift new file mode 100644 index 00000000..f0665207 --- /dev/null +++ b/Shared/Objects/XtreamServer.swift @@ -0,0 +1,67 @@ +// +// 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 Foundation + +/// Represents an Xtream Codes server configuration +struct XtreamServer: Codable, Hashable, Identifiable { + + var id: String + var name: String + var url: URL + var username: String + var password: String + + init( + id: String = UUID().uuidString, + name: String, + url: URL, + username: String, + password: String + ) { + self.id = id + self.name = name + self.url = url + self.username = username + self.password = password + } + + /// Base API URL for this server + var apiURL: URL { + url.appendingPathComponent("player_api.php") + } + + /// Construct authenticated API URL with username and password + func authenticatedURL(parameters: [URLQueryItem] = []) -> URL? { + var components = URLComponents(url: apiURL, resolvingAgainstBaseURL: false) + + var queryItems = [ + URLQueryItem(name: "username", value: username), + URLQueryItem(name: "password", value: password), + ] + + queryItems.append(contentsOf: parameters) + components?.queryItems = queryItems + + return components?.url + } +} + +// MARK: - Defaults Bridge + +extension XtreamServer: Defaults.Serializable {} + +extension Defaults.Keys { + + /// Current active Xtream server + static let currentXtreamServerID = Key("currentXtreamServerID", default: nil) + + /// All saved Xtream servers + static let xtreamServers = Key<[XtreamServer]>("xtreamServers", default: []) +} diff --git a/Shared/Services/LogManager.swift b/Shared/Services/LogManager.swift index 0e8f7159..0c14635d 100644 --- a/Shared/Services/LogManager.swift +++ b/Shared/Services/LogManager.swift @@ -14,7 +14,7 @@ import Pulse // TODO: cleanup extension Container { - var logService: Factory { self { Logger(label: "org.ashik.jellypig") }.singleton } + var logService: Factory { self { Logger(label: "se.ashik.jellyflood") }.singleton } var pulseNetworkLogger: Factory { self { diff --git a/Shared/Services/SwiftfinDefaults 2.swift b/Shared/Services/SwiftfinDefaults 2.swift new file mode 100644 index 00000000..e0c5ec67 --- /dev/null +++ b/Shared/Services/SwiftfinDefaults 2.swift @@ -0,0 +1,268 @@ +// +// 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 Foundation +import SwiftUI +import UIKit + +// TODO: organize +// TODO: all user settings could be moved to `StoredValues`? + +// Note: Only use Defaults for basic single-value settings. +// For larger data types and collections, use `StoredValue` instead. + +// MARK: Suites + +extension UserDefaults { + + // MARK: App + + /// Settings that should apply to the app + static let appSuite = UserDefaults(suiteName: "swiftfinApp")! + + // MARK: Usser + + // TODO: the Factory resolver cannot be used because it would cause freezes, but + // the Defaults value should always be in sync with the latest user and what + // views properly expect. However, this feels like a hack and should be changed? + static var currentUserSuite: UserDefaults { + switch Defaults[.lastSignedInUserID] { + case .signedOut: + return userSuite(id: "default") + case let .signedIn(userID): + return userSuite(id: userID) + } + } + + static func userSuite(id: String) -> UserDefaults { + UserDefaults(suiteName: id)! + } +} + +private extension Defaults.Keys { + + static func AppKey(_ name: String) -> Key { + Key(name, suite: .appSuite) + } + + static func AppKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static func UserKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .currentUserSuite) + } +} + +// MARK: App + +extension Defaults.Keys { + + /// The _real_ accent color key to be used. + /// + /// This is set externally whenever the app or user accent colors change, + /// depending on the current app state. + static var accentColor: Key = AppKey("accentColor", default: .jellyfinPurple) + + /// The _real_ appearance key to be used. + /// + /// This is set externally whenever the app or user appearances change, + /// depending on the current app state. + static let appearance: Key = AppKey("appearance", default: .system) + + /// The appearance default for non-user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static let appAppearance: Key = AppKey("appAppearance", default: .system) + + static let backgroundSignOutInterval: Key = AppKey("backgroundSignOutInterval", default: 3600) + static let backgroundTimeStamp: Key = AppKey("backgroundTimeStamp", default: Date.now) + static let lastSignedInUserID: Key = AppKey("lastSignedInUserID", default: .signedOut) + + static let selectUserDisplayType: Key = AppKey("selectUserDisplayType", default: .grid) + static let selectUserServerSelection: Key = AppKey("selectUserServerSelection", default: .all) + static let selectUserAllServersSplashscreen: Key = AppKey("selectUserAllServersSplashscreen", default: .all) + static let selectUserUseSplashscreen: Key = AppKey("selectUserUseSplashscreen", default: true) + + static let signOutOnBackground: Key = AppKey("signOutOnBackground", default: true) + static let signOutOnClose: Key = AppKey("signOutOnClose", default: false) +} + +// MARK: User + +extension Defaults.Keys { + + /// The accent color default for user contexts. + /// Only use for `set`, use `accentColor` for `get`. + static var userAccentColor: Key { UserKey("userAccentColor", default: .jellyfinPurple) } + + /// The appearance default for user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static var userAppearance: Key { UserKey("userAppearance", default: .system) } + + enum Customization { + + static let itemViewType: Key = UserKey("itemViewType", default: .compactLogo) + + static let showPosterLabels: Key = UserKey("showPosterLabels", default: true) + static let nextUpPosterType: Key = UserKey("nextUpPosterType", default: .portrait) + static let recentlyAddedPosterType: Key = UserKey("recentlyAddedPosterType", default: .portrait) + static let latestInLibraryPosterType: Key = UserKey("latestInLibraryPosterType", default: .portrait) + static let shouldShowMissingSeasons: Key = UserKey("shouldShowMissingSeasons", default: true) + static let shouldShowMissingEpisodes: Key = UserKey("shouldShowMissingEpisodes", default: true) + static let similarPosterType: Key = UserKey("similarPosterType", default: .portrait) + + // TODO: have search poster type by types of items if applicable + static let searchPosterType: Key = UserKey("searchPosterType", default: .portrait) + + enum CinematicItemViewType { + + static let usePrimaryImage: Key = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false) + } + + enum Episodes { + + static let useSeriesLandscapeBackdrop: Key = UserKey("useSeriesBackdrop", default: true) + } + + enum Indicators { + + static let showFavorited: Key = UserKey("showFavoritedIndicator", default: true) + static let showProgress: Key = UserKey("showProgressIndicator", default: true) + static let showUnplayed: Key = UserKey("showUnplayedIndicator", default: true) + static let showPlayed: Key = UserKey("showPlayedIndicator", default: true) + } + + enum Library { + + static let cinematicBackground: Key = UserKey("libraryCinematicBackground", default: true) + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "libraryEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + static let letterPickerEnabled: Key = UserKey("letterPickerEnabled", default: false) + static let letterPickerOrientation: Key = .init( + "letterPickerOrientation", default: .trailing + ) + static let displayType: Key = UserKey("libraryViewType", default: .grid) + static let posterType: Key = UserKey("libraryPosterType", default: .portrait) + static let listColumnCount: Key = UserKey("listColumnCount", default: 1) + static let randomImage: Key = UserKey("libraryRandomImage", default: true) + static let showFavorites: Key = UserKey("libraryShowFavorites", default: true) + + static let rememberLayout: Key = UserKey("libraryRememberLayout", default: false) + static let rememberSort: Key = UserKey("libraryRememberSort", default: false) + } + + enum Home { + static let showRecentlyAdded: Key = UserKey("showRecentlyAdded", default: true) + static let resumeNextUp: Key = UserKey("homeResumeNextUp", default: false) + static let maxNextUp: Key = UserKey( + "homeMaxNextUp", + default: 366 * 86400 + ) + } + + enum Search { + + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "searchEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + } + } + + enum VideoPlayer { + + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .max) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let autoPlayEnabled: Key = UserKey("autoPlayEnabled", default: true) + static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "barActionButtons", + default: VideoPlayerActionButton.defaultBarActionButtons + ) + static let jumpBackwardInterval: Key = UserKey("jumpBackwardLength", default: .fifteen) + static let jumpForwardInterval: Key = UserKey("jumpForwardLength", default: .fifteen) + static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "menuActionButtons", + default: VideoPlayerActionButton.defaultMenuActionButtons + ) + static let resumeOffset: Key = UserKey("resumeOffset", default: 0) + static let videoPlayerType: Key = UserKey("videoPlayerType", default: .swiftfin) + + enum Gesture { + + static let horizontalPanAction: Key = UserKey("videoPlayerHorizontalPanGesture", default: .none) + static let horizontalSwipeAction: Key = UserKey("videoPlayerhorizontalSwipeAction", default: .none) + static let longPressAction: Key = UserKey("videoPlayerLongPressGesture", default: .gestureLock) + static let multiTapGesture: Key = UserKey("videoPlayerMultiTapGesture", default: .none) + static let doubleTouchGesture: Key = UserKey("videoPlayerDoubleTouchGesture", default: .none) + static let pinchGesture: Key = UserKey("videoPlayerSwipeGesture", default: .aspectFill) + static let verticalPanLeftAction: Key = UserKey("videoPlayerverticalPanLeftAction", default: .none) + static let verticalPanRightAction: Key = UserKey("videoPlayerverticalPanRightAction", default: .none) + } + + enum Overlay { + + static let chapterSlider: Key = UserKey("chapterSlider", default: true) + + // Timestamp + static let trailingTimestampType: Key = UserKey("trailingTimestamp", default: .timeLeft) + } + + enum Playback { + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .auto) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let compatibilityMode: Key = UserKey("compatibilityMode", default: .auto) + static let customDeviceProfileAction: Key = UserKey("customDeviceProfileAction", default: .add) + static let rates: Key<[Float]> = UserKey("videoPlayerPlaybackRates", default: [0.5, 1.0, 1.25, 1.5, 2.0]) + } + + // TODO: transition into a SubtitleConfiguration instead of multiple types + enum Subtitle { + + static let subtitleColor: Key = UserKey("subtitleColor", default: .white) + static let subtitleFontName: Key = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName) + static let subtitleSize: Key = UserKey("subtitleSize", default: 9) + } + + enum Transition { + static let pauseOnBackground: Key = UserKey("playInBackground", default: true) + } + } + + // Experimental settings + enum Experimental { + + static let downloads: Key = UserKey("experimentalDownloads", default: false) + } + + // tvos specific + static let downActionShowsMenu: Key = UserKey("downActionShowsMenu", default: true) + static let confirmClose: Key = UserKey("confirmClose", default: false) +} + +// MARK: Debug + +#if DEBUG + +extension UserDefaults { + + static let debugSuite = UserDefaults(suiteName: "swiftfinstore-debug-defaults")! +} + +extension Defaults.Keys { + + static func DebugKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static let sendProgressReports: Key = DebugKey("sendProgressReports", default: true) +} +#endif diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift new file mode 100644 index 00000000..e0c5ec67 --- /dev/null +++ b/Shared/Services/SwiftfinDefaults.swift @@ -0,0 +1,268 @@ +// +// 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 Foundation +import SwiftUI +import UIKit + +// TODO: organize +// TODO: all user settings could be moved to `StoredValues`? + +// Note: Only use Defaults for basic single-value settings. +// For larger data types and collections, use `StoredValue` instead. + +// MARK: Suites + +extension UserDefaults { + + // MARK: App + + /// Settings that should apply to the app + static let appSuite = UserDefaults(suiteName: "swiftfinApp")! + + // MARK: Usser + + // TODO: the Factory resolver cannot be used because it would cause freezes, but + // the Defaults value should always be in sync with the latest user and what + // views properly expect. However, this feels like a hack and should be changed? + static var currentUserSuite: UserDefaults { + switch Defaults[.lastSignedInUserID] { + case .signedOut: + return userSuite(id: "default") + case let .signedIn(userID): + return userSuite(id: userID) + } + } + + static func userSuite(id: String) -> UserDefaults { + UserDefaults(suiteName: id)! + } +} + +private extension Defaults.Keys { + + static func AppKey(_ name: String) -> Key { + Key(name, suite: .appSuite) + } + + static func AppKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static func UserKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .currentUserSuite) + } +} + +// MARK: App + +extension Defaults.Keys { + + /// The _real_ accent color key to be used. + /// + /// This is set externally whenever the app or user accent colors change, + /// depending on the current app state. + static var accentColor: Key = AppKey("accentColor", default: .jellyfinPurple) + + /// The _real_ appearance key to be used. + /// + /// This is set externally whenever the app or user appearances change, + /// depending on the current app state. + static let appearance: Key = AppKey("appearance", default: .system) + + /// The appearance default for non-user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static let appAppearance: Key = AppKey("appAppearance", default: .system) + + static let backgroundSignOutInterval: Key = AppKey("backgroundSignOutInterval", default: 3600) + static let backgroundTimeStamp: Key = AppKey("backgroundTimeStamp", default: Date.now) + static let lastSignedInUserID: Key = AppKey("lastSignedInUserID", default: .signedOut) + + static let selectUserDisplayType: Key = AppKey("selectUserDisplayType", default: .grid) + static let selectUserServerSelection: Key = AppKey("selectUserServerSelection", default: .all) + static let selectUserAllServersSplashscreen: Key = AppKey("selectUserAllServersSplashscreen", default: .all) + static let selectUserUseSplashscreen: Key = AppKey("selectUserUseSplashscreen", default: true) + + static let signOutOnBackground: Key = AppKey("signOutOnBackground", default: true) + static let signOutOnClose: Key = AppKey("signOutOnClose", default: false) +} + +// MARK: User + +extension Defaults.Keys { + + /// The accent color default for user contexts. + /// Only use for `set`, use `accentColor` for `get`. + static var userAccentColor: Key { UserKey("userAccentColor", default: .jellyfinPurple) } + + /// The appearance default for user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static var userAppearance: Key { UserKey("userAppearance", default: .system) } + + enum Customization { + + static let itemViewType: Key = UserKey("itemViewType", default: .compactLogo) + + static let showPosterLabels: Key = UserKey("showPosterLabels", default: true) + static let nextUpPosterType: Key = UserKey("nextUpPosterType", default: .portrait) + static let recentlyAddedPosterType: Key = UserKey("recentlyAddedPosterType", default: .portrait) + static let latestInLibraryPosterType: Key = UserKey("latestInLibraryPosterType", default: .portrait) + static let shouldShowMissingSeasons: Key = UserKey("shouldShowMissingSeasons", default: true) + static let shouldShowMissingEpisodes: Key = UserKey("shouldShowMissingEpisodes", default: true) + static let similarPosterType: Key = UserKey("similarPosterType", default: .portrait) + + // TODO: have search poster type by types of items if applicable + static let searchPosterType: Key = UserKey("searchPosterType", default: .portrait) + + enum CinematicItemViewType { + + static let usePrimaryImage: Key = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false) + } + + enum Episodes { + + static let useSeriesLandscapeBackdrop: Key = UserKey("useSeriesBackdrop", default: true) + } + + enum Indicators { + + static let showFavorited: Key = UserKey("showFavoritedIndicator", default: true) + static let showProgress: Key = UserKey("showProgressIndicator", default: true) + static let showUnplayed: Key = UserKey("showUnplayedIndicator", default: true) + static let showPlayed: Key = UserKey("showPlayedIndicator", default: true) + } + + enum Library { + + static let cinematicBackground: Key = UserKey("libraryCinematicBackground", default: true) + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "libraryEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + static let letterPickerEnabled: Key = UserKey("letterPickerEnabled", default: false) + static let letterPickerOrientation: Key = .init( + "letterPickerOrientation", default: .trailing + ) + static let displayType: Key = UserKey("libraryViewType", default: .grid) + static let posterType: Key = UserKey("libraryPosterType", default: .portrait) + static let listColumnCount: Key = UserKey("listColumnCount", default: 1) + static let randomImage: Key = UserKey("libraryRandomImage", default: true) + static let showFavorites: Key = UserKey("libraryShowFavorites", default: true) + + static let rememberLayout: Key = UserKey("libraryRememberLayout", default: false) + static let rememberSort: Key = UserKey("libraryRememberSort", default: false) + } + + enum Home { + static let showRecentlyAdded: Key = UserKey("showRecentlyAdded", default: true) + static let resumeNextUp: Key = UserKey("homeResumeNextUp", default: false) + static let maxNextUp: Key = UserKey( + "homeMaxNextUp", + default: 366 * 86400 + ) + } + + enum Search { + + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "searchEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + } + } + + enum VideoPlayer { + + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .max) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let autoPlayEnabled: Key = UserKey("autoPlayEnabled", default: true) + static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "barActionButtons", + default: VideoPlayerActionButton.defaultBarActionButtons + ) + static let jumpBackwardInterval: Key = UserKey("jumpBackwardLength", default: .fifteen) + static let jumpForwardInterval: Key = UserKey("jumpForwardLength", default: .fifteen) + static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "menuActionButtons", + default: VideoPlayerActionButton.defaultMenuActionButtons + ) + static let resumeOffset: Key = UserKey("resumeOffset", default: 0) + static let videoPlayerType: Key = UserKey("videoPlayerType", default: .swiftfin) + + enum Gesture { + + static let horizontalPanAction: Key = UserKey("videoPlayerHorizontalPanGesture", default: .none) + static let horizontalSwipeAction: Key = UserKey("videoPlayerhorizontalSwipeAction", default: .none) + static let longPressAction: Key = UserKey("videoPlayerLongPressGesture", default: .gestureLock) + static let multiTapGesture: Key = UserKey("videoPlayerMultiTapGesture", default: .none) + static let doubleTouchGesture: Key = UserKey("videoPlayerDoubleTouchGesture", default: .none) + static let pinchGesture: Key = UserKey("videoPlayerSwipeGesture", default: .aspectFill) + static let verticalPanLeftAction: Key = UserKey("videoPlayerverticalPanLeftAction", default: .none) + static let verticalPanRightAction: Key = UserKey("videoPlayerverticalPanRightAction", default: .none) + } + + enum Overlay { + + static let chapterSlider: Key = UserKey("chapterSlider", default: true) + + // Timestamp + static let trailingTimestampType: Key = UserKey("trailingTimestamp", default: .timeLeft) + } + + enum Playback { + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .auto) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let compatibilityMode: Key = UserKey("compatibilityMode", default: .auto) + static let customDeviceProfileAction: Key = UserKey("customDeviceProfileAction", default: .add) + static let rates: Key<[Float]> = UserKey("videoPlayerPlaybackRates", default: [0.5, 1.0, 1.25, 1.5, 2.0]) + } + + // TODO: transition into a SubtitleConfiguration instead of multiple types + enum Subtitle { + + static let subtitleColor: Key = UserKey("subtitleColor", default: .white) + static let subtitleFontName: Key = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName) + static let subtitleSize: Key = UserKey("subtitleSize", default: 9) + } + + enum Transition { + static let pauseOnBackground: Key = UserKey("playInBackground", default: true) + } + } + + // Experimental settings + enum Experimental { + + static let downloads: Key = UserKey("experimentalDownloads", default: false) + } + + // tvos specific + static let downActionShowsMenu: Key = UserKey("downActionShowsMenu", default: true) + static let confirmClose: Key = UserKey("confirmClose", default: false) +} + +// MARK: Debug + +#if DEBUG + +extension UserDefaults { + + static let debugSuite = UserDefaults(suiteName: "swiftfinstore-debug-defaults")! +} + +extension Defaults.Keys { + + static func DebugKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static let sendProgressReports: Key = DebugKey("sendProgressReports", default: true) +} +#endif diff --git a/Shared/Services/XtreamAPIClient.swift b/Shared/Services/XtreamAPIClient.swift new file mode 100644 index 00000000..419c29ff --- /dev/null +++ b/Shared/Services/XtreamAPIClient.swift @@ -0,0 +1,214 @@ +// +// 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 Foundation + +/// Xtream Codes API Client for Live TV and VOD streams +class XtreamAPIClient { + + private let server: XtreamServer + + init(server: XtreamServer) { + self.server = server + } + + // MARK: - Authentication + + /// Server info response from Xtream API + struct ServerInfo: Codable { + let userInfo: UserInfo? + let serverInfo: ServerDetails? + + struct UserInfo: Codable { + let username: String? + let password: String? + let message: String? + let auth: Int? + let status: String? + let exp_date: String? + let isTrial: String? + let activeCons: String? + let createdAt: String? + let maxConnections: String? + + enum CodingKeys: String, CodingKey { + case username + case password + case message + case auth + case status + case exp_date + case isTrial = "is_trial" + case activeCons = "active_cons" + case createdAt = "created_at" + case maxConnections = "max_connections" + } + } + + struct ServerDetails: Codable { + let url: String? + let port: String? + let httpsPort: String? + let serverProtocol: String? + let rtmpPort: String? + let timezone: String? + let timestampNow: Int? + let timeNow: String? + + enum CodingKeys: String, CodingKey { + case url + case port + case timezone + case httpsPort = "https_port" + case serverProtocol = "server_protocol" + case rtmpPort = "rtmp_port" + case timestampNow = "timestamp_now" + case timeNow = "time_now" + } + } + + enum CodingKeys: String, CodingKey { + case userInfo = "user_info" + case serverInfo = "server_info" + } + } + + /// Test connection to Xtream server and authenticate + func testConnection() async throws -> ServerInfo { + guard let url = server.authenticatedURL(parameters: []) else { + throw XtreamAPIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 10 + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw XtreamAPIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw XtreamAPIError.httpError(statusCode: httpResponse.statusCode) + } + + let serverInfo = try JSONDecoder().decode(ServerInfo.self, from: data) + + // Check if authentication was successful + if let auth = serverInfo.userInfo?.auth, auth == 0 { + throw XtreamAPIError.authenticationFailed(message: serverInfo.userInfo?.message ?? "Authentication failed") + } + + return serverInfo + } + + // MARK: - Live Channels + + struct LiveCategory: Codable, Identifiable { + let categoryId: String + let categoryName: String + let parentId: Int? + + var id: String { categoryId } + + enum CodingKeys: String, CodingKey { + case categoryId = "category_id" + case categoryName = "category_name" + case parentId = "parent_id" + } + } + + struct LiveChannel: Codable, Identifiable { + let num: Int? + let name: String? + let streamType: String? + let streamId: Int? + let streamIcon: String? + let epgChannelId: String? + let added: String? + let categoryId: String? + let customSid: String? + let tvArchive: Int? + let directSource: String? + let tvArchiveDuration: Int? + + var id: Int { streamId ?? 0 } + + enum CodingKeys: String, CodingKey { + case num + case name + case added + case streamType = "stream_type" + case streamId = "stream_id" + case streamIcon = "stream_icon" + case epgChannelId = "epg_channel_id" + case categoryId = "category_id" + case customSid = "custom_sid" + case tvArchive = "tv_archive" + case directSource = "direct_source" + case tvArchiveDuration = "tv_archive_duration" + } + } + + /// Get live TV categories + func getLiveCategories() async throws -> [LiveCategory] { + guard let url = server.authenticatedURL(parameters: [ + URLQueryItem(name: "action", value: "get_live_categories"), + ]) else { + throw XtreamAPIError.invalidURL + } + + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([LiveCategory].self, from: data) + } + + /// Get live TV channels + func getLiveChannels(categoryId: String? = nil) async throws -> [LiveChannel] { + var parameters = [URLQueryItem(name: "action", value: "get_live_streams")] + + if let categoryId { + parameters.append(URLQueryItem(name: "category_id", value: categoryId)) + } + + guard let url = server.authenticatedURL(parameters: parameters) else { + throw XtreamAPIError.invalidURL + } + + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([LiveChannel].self, from: data) + } + + /// Get live stream URL for a channel + func getLiveStreamURL(streamId: Int) -> URL? { + let urlString = "\(server.url.absoluteString)/live/\(server.username)/\(server.password)/\(streamId).ts" + return URL(string: urlString) + } +} + +// MARK: - Errors + +enum XtreamAPIError: LocalizedError { + case invalidURL + case invalidResponse + case httpError(statusCode: Int) + case authenticationFailed(message: String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid Xtream server URL" + case .invalidResponse: + return "Invalid response from Xtream server" + case let .httpError(statusCode): + return "HTTP error: \(statusCode)" + case let .authenticationFailed(message): + return "Authentication failed: \(message)" + } + } +} diff --git a/Shared/Services/jellypigdefaults.swift b/Shared/Services/jellyflooddefaults.swift similarity index 100% rename from Shared/Services/jellypigdefaults.swift rename to Shared/Services/jellyflooddefaults.swift diff --git a/Shared/Services/jellypigdefaults 2.swift b/Shared/Services/jellypigdefaults 2.swift new file mode 100644 index 00000000..e0c5ec67 --- /dev/null +++ b/Shared/Services/jellypigdefaults 2.swift @@ -0,0 +1,268 @@ +// +// 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 Foundation +import SwiftUI +import UIKit + +// TODO: organize +// TODO: all user settings could be moved to `StoredValues`? + +// Note: Only use Defaults for basic single-value settings. +// For larger data types and collections, use `StoredValue` instead. + +// MARK: Suites + +extension UserDefaults { + + // MARK: App + + /// Settings that should apply to the app + static let appSuite = UserDefaults(suiteName: "swiftfinApp")! + + // MARK: Usser + + // TODO: the Factory resolver cannot be used because it would cause freezes, but + // the Defaults value should always be in sync with the latest user and what + // views properly expect. However, this feels like a hack and should be changed? + static var currentUserSuite: UserDefaults { + switch Defaults[.lastSignedInUserID] { + case .signedOut: + return userSuite(id: "default") + case let .signedIn(userID): + return userSuite(id: userID) + } + } + + static func userSuite(id: String) -> UserDefaults { + UserDefaults(suiteName: id)! + } +} + +private extension Defaults.Keys { + + static func AppKey(_ name: String) -> Key { + Key(name, suite: .appSuite) + } + + static func AppKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static func UserKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .currentUserSuite) + } +} + +// MARK: App + +extension Defaults.Keys { + + /// The _real_ accent color key to be used. + /// + /// This is set externally whenever the app or user accent colors change, + /// depending on the current app state. + static var accentColor: Key = AppKey("accentColor", default: .jellyfinPurple) + + /// The _real_ appearance key to be used. + /// + /// This is set externally whenever the app or user appearances change, + /// depending on the current app state. + static let appearance: Key = AppKey("appearance", default: .system) + + /// The appearance default for non-user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static let appAppearance: Key = AppKey("appAppearance", default: .system) + + static let backgroundSignOutInterval: Key = AppKey("backgroundSignOutInterval", default: 3600) + static let backgroundTimeStamp: Key = AppKey("backgroundTimeStamp", default: Date.now) + static let lastSignedInUserID: Key = AppKey("lastSignedInUserID", default: .signedOut) + + static let selectUserDisplayType: Key = AppKey("selectUserDisplayType", default: .grid) + static let selectUserServerSelection: Key = AppKey("selectUserServerSelection", default: .all) + static let selectUserAllServersSplashscreen: Key = AppKey("selectUserAllServersSplashscreen", default: .all) + static let selectUserUseSplashscreen: Key = AppKey("selectUserUseSplashscreen", default: true) + + static let signOutOnBackground: Key = AppKey("signOutOnBackground", default: true) + static let signOutOnClose: Key = AppKey("signOutOnClose", default: false) +} + +// MARK: User + +extension Defaults.Keys { + + /// The accent color default for user contexts. + /// Only use for `set`, use `accentColor` for `get`. + static var userAccentColor: Key { UserKey("userAccentColor", default: .jellyfinPurple) } + + /// The appearance default for user contexts. + /// /// Only use for `set`, use `appearance` for `get`. + static var userAppearance: Key { UserKey("userAppearance", default: .system) } + + enum Customization { + + static let itemViewType: Key = UserKey("itemViewType", default: .compactLogo) + + static let showPosterLabels: Key = UserKey("showPosterLabels", default: true) + static let nextUpPosterType: Key = UserKey("nextUpPosterType", default: .portrait) + static let recentlyAddedPosterType: Key = UserKey("recentlyAddedPosterType", default: .portrait) + static let latestInLibraryPosterType: Key = UserKey("latestInLibraryPosterType", default: .portrait) + static let shouldShowMissingSeasons: Key = UserKey("shouldShowMissingSeasons", default: true) + static let shouldShowMissingEpisodes: Key = UserKey("shouldShowMissingEpisodes", default: true) + static let similarPosterType: Key = UserKey("similarPosterType", default: .portrait) + + // TODO: have search poster type by types of items if applicable + static let searchPosterType: Key = UserKey("searchPosterType", default: .portrait) + + enum CinematicItemViewType { + + static let usePrimaryImage: Key = UserKey("cinematicItemViewTypeUsePrimaryImage", default: false) + } + + enum Episodes { + + static let useSeriesLandscapeBackdrop: Key = UserKey("useSeriesBackdrop", default: true) + } + + enum Indicators { + + static let showFavorited: Key = UserKey("showFavoritedIndicator", default: true) + static let showProgress: Key = UserKey("showProgressIndicator", default: true) + static let showUnplayed: Key = UserKey("showUnplayedIndicator", default: true) + static let showPlayed: Key = UserKey("showPlayedIndicator", default: true) + } + + enum Library { + + static let cinematicBackground: Key = UserKey("libraryCinematicBackground", default: true) + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "libraryEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + static let letterPickerEnabled: Key = UserKey("letterPickerEnabled", default: false) + static let letterPickerOrientation: Key = .init( + "letterPickerOrientation", default: .trailing + ) + static let displayType: Key = UserKey("libraryViewType", default: .grid) + static let posterType: Key = UserKey("libraryPosterType", default: .portrait) + static let listColumnCount: Key = UserKey("listColumnCount", default: 1) + static let randomImage: Key = UserKey("libraryRandomImage", default: true) + static let showFavorites: Key = UserKey("libraryShowFavorites", default: true) + + static let rememberLayout: Key = UserKey("libraryRememberLayout", default: false) + static let rememberSort: Key = UserKey("libraryRememberSort", default: false) + } + + enum Home { + static let showRecentlyAdded: Key = UserKey("showRecentlyAdded", default: true) + static let resumeNextUp: Key = UserKey("homeResumeNextUp", default: false) + static let maxNextUp: Key = UserKey( + "homeMaxNextUp", + default: 366 * 86400 + ) + } + + enum Search { + + static let enabledDrawerFilters: Key<[ItemFilterType]> = UserKey( + "searchEnabledDrawerFilters", + default: ItemFilterType.allCases + ) + } + } + + enum VideoPlayer { + + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .max) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let autoPlayEnabled: Key = UserKey("autoPlayEnabled", default: true) + static let barActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "barActionButtons", + default: VideoPlayerActionButton.defaultBarActionButtons + ) + static let jumpBackwardInterval: Key = UserKey("jumpBackwardLength", default: .fifteen) + static let jumpForwardInterval: Key = UserKey("jumpForwardLength", default: .fifteen) + static let menuActionButtons: Key<[VideoPlayerActionButton]> = UserKey( + "menuActionButtons", + default: VideoPlayerActionButton.defaultMenuActionButtons + ) + static let resumeOffset: Key = UserKey("resumeOffset", default: 0) + static let videoPlayerType: Key = UserKey("videoPlayerType", default: .swiftfin) + + enum Gesture { + + static let horizontalPanAction: Key = UserKey("videoPlayerHorizontalPanGesture", default: .none) + static let horizontalSwipeAction: Key = UserKey("videoPlayerhorizontalSwipeAction", default: .none) + static let longPressAction: Key = UserKey("videoPlayerLongPressGesture", default: .gestureLock) + static let multiTapGesture: Key = UserKey("videoPlayerMultiTapGesture", default: .none) + static let doubleTouchGesture: Key = UserKey("videoPlayerDoubleTouchGesture", default: .none) + static let pinchGesture: Key = UserKey("videoPlayerSwipeGesture", default: .aspectFill) + static let verticalPanLeftAction: Key = UserKey("videoPlayerverticalPanLeftAction", default: .none) + static let verticalPanRightAction: Key = UserKey("videoPlayerverticalPanRightAction", default: .none) + } + + enum Overlay { + + static let chapterSlider: Key = UserKey("chapterSlider", default: true) + + // Timestamp + static let trailingTimestampType: Key = UserKey("trailingTimestamp", default: .timeLeft) + } + + enum Playback { + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .auto) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let compatibilityMode: Key = UserKey("compatibilityMode", default: .auto) + static let customDeviceProfileAction: Key = UserKey("customDeviceProfileAction", default: .add) + static let rates: Key<[Float]> = UserKey("videoPlayerPlaybackRates", default: [0.5, 1.0, 1.25, 1.5, 2.0]) + } + + // TODO: transition into a SubtitleConfiguration instead of multiple types + enum Subtitle { + + static let subtitleColor: Key = UserKey("subtitleColor", default: .white) + static let subtitleFontName: Key = UserKey("subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName) + static let subtitleSize: Key = UserKey("subtitleSize", default: 9) + } + + enum Transition { + static let pauseOnBackground: Key = UserKey("playInBackground", default: true) + } + } + + // Experimental settings + enum Experimental { + + static let downloads: Key = UserKey("experimentalDownloads", default: false) + } + + // tvos specific + static let downActionShowsMenu: Key = UserKey("downActionShowsMenu", default: true) + static let confirmClose: Key = UserKey("confirmClose", default: false) +} + +// MARK: Debug + +#if DEBUG + +extension UserDefaults { + + static let debugSuite = UserDefaults(suiteName: "swiftfinstore-debug-defaults")! +} + +extension Defaults.Keys { + + static func DebugKey(_ name: String, default: Value) -> Key { + Key(name, default: `default`, suite: .appSuite) + } + + static let sendProgressReports: Key = DebugKey("sendProgressReports", default: true) +} +#endif diff --git a/Shared/ViewModels/ConnectToXtreamViewModel.swift b/Shared/ViewModels/ConnectToXtreamViewModel.swift new file mode 100644 index 00000000..cefb0ccb --- /dev/null +++ b/Shared/ViewModels/ConnectToXtreamViewModel.swift @@ -0,0 +1,183 @@ +// +// 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 Combine +import Defaults +import Foundation + +final class ConnectToXtreamViewModel: ViewModel, Eventful, Stateful { + + // MARK: Event + + enum Event { + case connected(XtreamServer) + case error(XtreamAPIError) + } + + // MARK: Action + + enum Action: Equatable { + case cancel + case connect(name: String, url: String, username: String, password: String) + case testConnection(name: String, url: String, username: String, password: String) + } + + // MARK: State + + enum State: Hashable { + case connecting + case initial + case testing + } + + @Published + var state: State = .initial + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private var connectTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .init() + + func respond(to action: Action) -> State { + switch action { + case .cancel: + connectTask?.cancel() + return .initial + + case let .connect(name, urlString, username, password): + connectTask?.cancel() + + connectTask = Task { + do { + let server = try await connectToXtream( + name: name, + url: urlString, + username: username, + password: password + ) + + await MainActor.run { + self.eventSubject.send(.connected(server)) + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter + } catch let error as XtreamAPIError { + await MainActor.run { + self.eventSubject.send(.error(error)) + self.state = .initial + } + } catch { + await MainActor.run { + self.eventSubject.send(.error(.invalidResponse)) + self.state = .initial + } + } + } + .asAnyCancellable() + + return .connecting + + case let .testConnection(name, urlString, username, password): + connectTask?.cancel() + + connectTask = Task { + do { + _ = try await connectToXtream( + name: name, + url: urlString, + username: username, + password: password + ) + + await MainActor.run { + self.state = .initial + } + } catch is CancellationError { + // cancel doesn't matter + } catch let error as XtreamAPIError { + await MainActor.run { + self.eventSubject.send(.error(error)) + self.state = .initial + } + } catch { + await MainActor.run { + self.eventSubject.send(.error(.invalidResponse)) + self.state = .initial + } + } + } + .asAnyCancellable() + + return .testing + } + } + + private func connectToXtream( + name: String, + url urlString: String, + username: String, + password: String + ) async throws -> XtreamServer { + + let formattedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .objectReplacement) + .trimmingCharacters(in: ["/"]) + .prepending("http://", if: !urlString.contains("://")) + + guard let url = URL(string: formattedURL) else { + throw XtreamAPIError.invalidURL + } + + let server = XtreamServer( + name: name.isEmpty ? "Xtream Server" : name, + url: url, + username: username, + password: password + ) + + // Test connection + let client = XtreamAPIClient(server: server) + _ = try await client.testConnection() + + return server + } + + func saveServer(_ server: XtreamServer) { + var servers = Defaults[.xtreamServers] + + // Check if server with same ID exists and update, otherwise append + if let index = servers.firstIndex(where: { $0.id == server.id }) { + servers[index] = server + } else { + servers.append(server) + } + + Defaults[.xtreamServers] = servers + + // Set as current server if it's the first one + if Defaults[.currentXtreamServerID] == nil { + Defaults[.currentXtreamServerID] = server.id + } + } + + func deleteServer(_ server: XtreamServer) { + var servers = Defaults[.xtreamServers] + servers.removeAll { $0.id == server.id } + Defaults[.xtreamServers] = servers + + // Clear current server if deleted + if Defaults[.currentXtreamServerID] == server.id { + Defaults[.currentXtreamServerID] = servers.first?.id + } + } +} diff --git a/Shared/ViewModels/LiveVideoPlayerManager.swift b/Shared/ViewModels/LiveVideoPlayerManager.swift index 74ec3dc0..663b1aba 100644 --- a/Shared/ViewModels/LiveVideoPlayerManager.swift +++ b/Shared/ViewModels/LiveVideoPlayerManager.swift @@ -23,10 +23,17 @@ final class LiveVideoPlayerManager: VideoPlayerManager { super.init() Task { - let viewModel = try await item.liveVideoPlayerViewModel(with: mediaSource, logger: logger) + do { + logger.info("LiveVideoPlayerManager: Starting playback setup for channel: \(item.displayTitle)") + let viewModel = try await item.liveVideoPlayerViewModel(with: mediaSource, logger: logger) - await MainActor.run { - self.currentViewModel = viewModel + await MainActor.run { + logger.info("LiveVideoPlayerManager: Successfully created view model, setting currentViewModel") + self.currentViewModel = viewModel + } + } catch { + logger.error("LiveVideoPlayerManager: Failed to create video player view model - \(error.localizedDescription)") + // TODO: Need to display error to user - VideoPlayerManager doesn't have error property } } } @@ -35,15 +42,25 @@ final class LiveVideoPlayerManager: VideoPlayerManager { super.init() Task { - guard let channel = try? await self.getChannel(for: program), let mediaSource = channel.mediaSources?.first else { - assertionFailure("No channel for program?") - return - } + do { + logger.info("LiveVideoPlayerManager: Getting channel for program: \(program.displayTitle)") + guard let channel = try await self.getChannel(for: program), let mediaSource = channel.mediaSources?.first else { + let errorMsg = "No channel or media source for program" + logger.error("LiveVideoPlayerManager: \(errorMsg)") + // TODO: Need to display error to user - VideoPlayerManager doesn't have error property + return + } - let viewModel = try await program.liveVideoPlayerViewModel(with: mediaSource, logger: logger) + logger.info("LiveVideoPlayerManager: Found channel, creating view model") + let viewModel = try await program.liveVideoPlayerViewModel(with: mediaSource, logger: logger) - await MainActor.run { - self.currentViewModel = viewModel + await MainActor.run { + logger.info("LiveVideoPlayerManager: Successfully created view model") + self.currentViewModel = viewModel + } + } catch { + logger.error("LiveVideoPlayerManager: Failed to set up playback - \(error.localizedDescription)") + // TODO: Need to display error to user - VideoPlayerManager doesn't have error property } } } diff --git a/Shared/Views/ConnecToServerView/Components/LocalServerButton.swift b/Shared/Views/ConnecToServerView/Components/LocalServerButton.swift new file mode 100644 index 00000000..a23d45c7 --- /dev/null +++ b/Shared/Views/ConnecToServerView/Components/LocalServerButton.swift @@ -0,0 +1,48 @@ +// +// 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 Combine +import Defaults +import SwiftUI + +extension ConnectToServerView { + + struct LocalServerButton: View { + + let server: ServerState + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + VStack(alignment: .leading) { + Text(server.name) + .font(.headline) + .fontWeight(.semibold) + + Text(server.currentURL.absoluteString) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .fontWeight(.regular) + .foregroundStyle(.secondary) + } + #if os(tvOS) + .padding() + #endif + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + } + } +} diff --git a/Shared/Views/ConnecToServerView/ConnectToServerView.swift b/Shared/Views/ConnecToServerView/ConnectToServerView.swift new file mode 100644 index 00000000..f50750da --- /dev/null +++ b/Shared/Views/ConnecToServerView/ConnectToServerView.swift @@ -0,0 +1,163 @@ +// +// 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 Combine +import Defaults +import SwiftUI + +struct ConnectToServerView: View { + + @Default(.accentColor) + private var accentColor + + @FocusState + private var isURLFocused: Bool + + @Router + private var router + + @StateObject + private var viewModel = ConnectToServerViewModel() + + @State + private var duplicateServer: ServerState? = nil + @State + private var isPresentingDuplicateServer: Bool = false + @State + private var url: String = "" + + private let timer = Timer.publish(every: 12, on: .main, in: .common).autoconnect() + + private func onEvent(_ event: ConnectToServerViewModel._Event) { + switch event { + case let .connected(server): + UIDevice.feedback(.success) + Notifications[.didConnectToServer].post(server) + router.dismiss() + case let .duplicateServer(server): + UIDevice.feedback(.warning) + duplicateServer = server + isPresentingDuplicateServer = true + } + } + + @ViewBuilder + private var connectSection: some View { + Section(L10n.connectToServer) { + TextField(L10n.serverURL, text: $url) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .focused($isURLFocused) + } + + if viewModel.state == .connecting { + ListRowButton(L10n.cancel) { + viewModel.cancel() + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.connect) { + isURLFocused = false + viewModel.connect(url: url) + } + .disabled(url.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(url.isEmpty ? 0.5 : 1) + } + } + + // MARK: - Local Servers Section + + @ViewBuilder + private var localServersSection: some View { + Section(L10n.localServers) { + if viewModel.localServers.isEmpty { + L10n.noLocalServersFound.text + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + ForEach(viewModel.localServers) { server in + LocalServerButton(server: server) { + url = server.currentURL.absoluteString + viewModel.connect(url: server.currentURL.absoluteString) + } + } + } + } + } + + @ViewBuilder + private var contentView: some View { + #if os(iOS) + List { + connectSection + + localServersSection + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton(disabled: viewModel.state == .connecting) { + router.dismiss() + } + #else + SplitLoginWindowView( + isLoading: viewModel.state == .connecting + ) { + connectSection + } trailingContentView: { + localServersSection + } + #endif + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.connect) + .interactiveDismissDisabled(viewModel.state == .connecting) + .onFirstAppear { + isURLFocused = true + viewModel.searchForServers() + } + .onReceive(timer) { _ in + guard viewModel.state != .connecting else { return } + viewModel.searchForServers() + } + .onReceive(viewModel.events, perform: onEvent) + .onReceive(viewModel.$error) { error in + guard error != nil else { return } + UIDevice.feedback(.error) + isURLFocused = true + } + .topBarTrailing { + if viewModel.state == .connecting { + ProgressView() + } + } + .alert( + L10n.server.text, + isPresented: $isPresentingDuplicateServer, + presenting: duplicateServer + ) { server in + Button(L10n.dismiss, role: .destructive) + + Button(L10n.addURL) { + viewModel.addNewURL(serverState: server) + router.dismiss() + } + } message: { server in + L10n.serverAlreadyExistsPrompt(server.name).text + } + .errorMessage($viewModel.error) + } +} diff --git a/Shared/Views/MediaView/Components/MediaItem.swift b/Shared/Views/MediaView/Components/MediaItem.swift new file mode 100644 index 00000000..3ca2d8eb --- /dev/null +++ b/Shared/Views/MediaView/Components/MediaItem.swift @@ -0,0 +1,129 @@ +// +// 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 + +// Note: the design reason to not have a local label always on top +// is to have the same failure/empty color for all views + +extension MediaView { + + // TODO: custom view for folders and tv (allow customization?) + // - differentiate between what media types are Swiftfin only + // which would allow some cleanup + // - allow server or random view per library? + // TODO: if local label on image, also needs to be in blurhash placeholder + struct MediaItem: View { + + @Default(.Customization.Library.randomImage) + private var useRandomImage + + @ObservedObject + private var viewModel: MediaViewModel + + @Namespace + private var namespace + + @State + private var imageSources: [ImageSource] = [] + + private let action: (Namespace.ID) -> Void + private let mediaType: MediaViewModel.MediaType + + init( + viewModel: MediaViewModel, + type: MediaViewModel.MediaType, + action: @escaping (Namespace.ID) -> Void + ) { + self.viewModel = viewModel + self.action = action + self.mediaType = type + } + + private var useTitleLabel: Bool { + useRandomImage || + mediaType == .downloads || + mediaType == .favorites + } + + private func setImageSources() { + Task { @MainActor in + if useRandomImage { + self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) + return + } + + if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } + } + } + + @ViewBuilder + private var titleLabel: some View { + Text(mediaType.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + + private func titleLabelOverlay(with content: some View) -> some View { + ZStack { + content + + Color.black + .opacity(0.5) + + titleLabel + .foregroundStyle(.white) + } + } + + var body: some View { + Button { + action(namespace) + } label: { + ImageView(imageSources) + .image { image in + if useTitleLabel { + titleLabelOverlay(with: image) + } else { + image + } + } + .placeholder { imageSource in + titleLabelOverlay(with: DefaultPlaceholderView(blurHash: imageSource.blurHash)) + } + .failure { + Color.secondarySystemFill + .opacity(0.75) + .overlay { + titleLabel + .foregroundColor(.primary) + } + } + .id(imageSources.hashValue) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .posterStyle(.landscape) + .backport + .matchedTransitionSource(id: "item", in: namespace) + } + .onFirstAppear(perform: setImageSources) + .backport + .onChange(of: useRandomImage) { _, _ in + setImageSources() + } + .buttonStyle(.card) + } + } +} diff --git a/Shared/Views/MediaView/MediaView.swift b/Shared/Views/MediaView/MediaView.swift new file mode 100644 index 00000000..ec06b7e5 --- /dev/null +++ b/Shared/Views/MediaView/MediaView.swift @@ -0,0 +1,95 @@ +// +// 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 CollectionVGrid +import Defaults +import Engine +import JellyfinAPI +import SwiftUI + +struct MediaView: View { + + @Router + private var router + + @StateObject + private var viewModel = MediaViewModel() + + private var layout: CollectionVGridLayout { + if UIDevice.isTV { + .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + } else if UIDevice.isPad { + .minWidth(200) + } else { + .columns(2) + } + } + + @ViewBuilder + private var content: some View { + CollectionVGrid( + uniqueElements: viewModel.mediaItems, + layout: layout + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) { namespace in + switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: .library(viewModel: viewModel), in: namespace) + case .downloads: + router.route(to: .downloadList) + case .favorites: + // TODO: favorites should have its own view instead of a library + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + id: "favorites", + filters: .favorites + ) + router.route(to: .library(viewModel: viewModel), in: namespace) + case .liveTV: + router.route(to: .liveTV) + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.refresh() + } + } + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .initial: + content + case .error: + viewModel.error.map { errorView(with: $0) } + case .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .navigationTitle(L10n.allMedia) + .onFirstAppear { + viewModel.refresh() + } + .if(UIDevice.isTV) { view in + view.toolbar(.hidden, for: .navigationBar) + } + } +} diff --git a/Shared/Views/QuickConnectView.swift b/Shared/Views/QuickConnectView.swift new file mode 100644 index 00000000..09fcc78a --- /dev/null +++ b/Shared/Views/QuickConnectView.swift @@ -0,0 +1,78 @@ +// +// 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 + +struct QuickConnectView: View { + + @Router + private var router + + @ObservedObject + private var viewModel: QuickConnect + + init(quickConnect: QuickConnect) { + self.viewModel = quickConnect + } + + private func pollingView(code: String) -> some View { + VStack(spacing: 20) { + BulletedList(spacing: 16) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(code) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .edgePadding() + } + + var body: some View { + ZStack { + switch viewModel.state { + case .authenticated, .idle, .retrievingCode: + ProgressView() + case let .polling(code): + pollingView(code: code) + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.start() + } + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .edgePadding() + .navigationTitle(L10n.quickConnect) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + #endif + .onFirstAppear { + viewModel.start() + } + .onDisappear { + viewModel.stop() + } + } +} diff --git a/Shared/Views/UserSignInView/Components/PublicUserButton.swift b/Shared/Views/UserSignInView/Components/PublicUserButton.swift new file mode 100644 index 00000000..ab49bb3f --- /dev/null +++ b/Shared/Views/UserSignInView/Components/PublicUserButton.swift @@ -0,0 +1,43 @@ +// +// 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 UserSignInView { + + struct PublicUserButton: View { + + let user: UserDto + let client: JellyfinClient + let action: () -> Void + + var body: some View { + Button(action: action) { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: client, + maxWidth: 240 + ) + ) + .frame(width: 150, height: 150) + .hoverEffect(.highlight) + + Text(user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + } + .backport + .buttonBorderShape(.circle) + .buttonStyle(.borderless) + .foregroundStyle(.primary, .secondary) + } + } +} diff --git a/Shared/Views/UserSignInView/Components/PublicUserRow.swift b/Shared/Views/UserSignInView/Components/PublicUserRow.swift new file mode 100644 index 00000000..e483031a --- /dev/null +++ b/Shared/Views/UserSignInView/Components/PublicUserRow.swift @@ -0,0 +1,48 @@ +// +// 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 UserSignInView { + + struct PublicUserRow: View { + + let user: UserDto + let client: JellyfinClient + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: client, + maxWidth: 120 + ) + ) + .frame(width: 50, height: 50) + + Text(user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .fontWeight(.regular) + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary, .secondary) + } + } +} diff --git a/Shared/Views/UserSignInView/UserSignInView.swift b/Shared/Views/UserSignInView/UserSignInView.swift new file mode 100644 index 00000000..25f119af --- /dev/null +++ b/Shared/Views/UserSignInView/UserSignInView.swift @@ -0,0 +1,342 @@ +// +// 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 CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import Logging +import SwiftUI + +struct UserSignInView: View { + + private enum Field: Hashable { + case username + case password + } + + @Environment(\.localUserAuthenticationAction) + private var authenticationAction + @Environment(\.quickConnectAction) + private var quickConnectAction + + @FocusState + private var focusedTextField: Field? + + @Router + private var router + + @State + private var accessPolicy: UserAccessPolicy = .none + @State + private var existingUser: UserSignInViewModel.UserStateDataPair? = nil + @State + private var isPresentingExistingUser: Bool = false + @State + private var password: String = "" + @State + private var pinHint: String = "" + @State + private var username: String = "" + + @StateObject + private var viewModel: UserSignInViewModel + + private let logger = Logger.swiftfin() + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: UserSignInViewModel(server: server)) + } + + private func handleEvent(_ event: UserSignInViewModel._Event) { + switch event { + case let .connected(user): + guard let authenticationAction else { + return + } + viewModel.save( + user: user, + authenticationAction: ( + authenticationAction, + accessPolicy, + accessPolicy.createReason( + user: user.state.state + ) + ), + evaluatedPolicyMap: .init(action: processEvaluatedPolicy) + ) + case let .existingUser(existingUser): + self.existingUser = existingUser + self.isPresentingExistingUser = true + case let .saved(user): + UIDevice.feedback(.success) + + router.dismiss() + Defaults[.lastSignedInUserID] = .signedIn(userID: user.id) + Container.shared.currentUserSession.reset() + Notifications[.didSignIn].post() + } + } + + private func runQuickConnect() { + Task { + do { + guard let secret = try await quickConnectAction?(client: viewModel.server.client) else { + logger.critical("QuickConnect called without necessary action!") + throw JellyfinAPIError(L10n.unknownError) + } + await viewModel.signInQuickConnect( + secret: secret + ) + } catch is CancellationError { + // ignore + } catch { + logger.error("QuickConnect failed with error: \(error.localizedDescription)") + await viewModel.error(JellyfinAPIError(L10n.taskFailed)) + } + } + } + + private func processEvaluatedPolicy( + _ evaluatedPolicy: any EvaluatedLocalUserAccessPolicy + ) -> any EvaluatedLocalUserAccessPolicy { + if let pinPolicy = evaluatedPolicy as? PinEvaluatedUserAccessPolicy { + return PinEvaluatedUserAccessPolicy( + pin: pinPolicy.pin, + pinHint: pinHint + ) + } + + return evaluatedPolicy + } + + // MARK: - Sign In Section + + @ViewBuilder + private var signInSection: some View { + Section { + TextField(L10n.username, text: $username) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: .username) + .onSubmit { + focusedTextField = .password + } + + SecureField( + L10n.password, + text: $password, + maskToggle: .enabled + ) + .onSubmit { + focusedTextField = nil + + viewModel.signIn( + username: username, + password: password + ) + } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedTextField, equals: .password) + } header: { + Text(L10n.signInToServer(viewModel.server.name)) + } footer: { + switch accessPolicy { + case .requireDeviceAuthentication: + Label(L10n.userDeviceAuthRequiredDescription, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + case .requirePin: + Label(L10n.userPinRequiredDescription, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + case .none: + EmptyView() + } + } + + if case .signingIn = viewModel.state { + ListRowButton(L10n.cancel, role: .cancel) { + viewModel.cancel() + } + } else { + ListRowButton(L10n.signIn) { + viewModel.signIn( + username: username, + password: password + ) + } + .disabled(username.isEmpty) + .foregroundStyle( + Color.jellyfinPurple.overlayColor, + Color.jellyfinPurple + ) + .opacity(username.isEmpty ? 0.5 : 1) + } + + if viewModel.isQuickConnectEnabled { + Section { + ListRowButton( + L10n.quickConnect, + action: runQuickConnect + ) + .disabled(viewModel.state == .signingIn) + .foregroundStyle( + Color.jellyfinPurple.overlayColor, + Color.jellyfinPurple + ) + } + } + + if let disclaimer = viewModel.serverDisclaimer { + Section(L10n.disclaimer) { + Text(disclaimer) + .font(.callout) + } + } + } + + // MARK: - Public Users Section + + @ViewBuilder + private var publicUsersSection: some View { + Section(L10n.publicUsers) { + if viewModel.publicUsers.isEmpty { + L10n.noPublicUsers.text + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + #if os(iOS) + ForEach(viewModel.publicUsers) { user in + PublicUserRow( + user: user, + client: viewModel.server.client + ) { + username = user.name ?? "" + password = "" + focusedTextField = .password + } + } + #else + LazyVGrid( + columns: Array(repeating: GridItem(.flexible()), count: 4), + spacing: 30 + ) { + ForEach(viewModel.publicUsers) { user in + PublicUserButton( + user: user, + client: viewModel.server.client + ) { + username = user.name ?? "" + password = "" + focusedTextField = .password + } + .environment(\.isOverComplexContent, true) + } + } + #endif + } + } + .disabled(viewModel.state == .signingIn) + } + + @ViewBuilder + private var contentView: some View { + #if os(iOS) + List { + signInSection + + publicUsersSection + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton(disabled: viewModel.state == .signingIn) { + router.dismiss() + } + .topBarTrailing { + if viewModel.state == .signingIn || viewModel.background.is(.gettingPublicData) { + ProgressView() + } + + Button(L10n.security, systemImage: "gearshape.fill") { + router.route( + to: .userSecurity( + pinHint: $pinHint, + accessPolicy: $accessPolicy + ) + ) + } + } + #else + SplitLoginWindowView( + isLoading: viewModel.state == .signingIn, + backgroundImageSource: viewModel.server.splashScreenImageSource + ) { + signInSection + } trailingContentView: { + publicUsersSection + } + #endif + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.signIn) + .interactiveDismissDisabled(viewModel.state == .signingIn) + .onReceive(viewModel.events, perform: handleEvent) + .onFirstAppear { + focusedTextField = .username + viewModel.getPublicData() + } + .alert( + L10n.duplicateUser, + isPresented: $isPresentingExistingUser, + presenting: existingUser + ) { existingUser in + + let userState = existingUser.state.state + let existingUserAccessPolicy = userState.accessPolicy + + Button(L10n.signIn) { + viewModel.saveExisting( + user: existingUser, + replaceForAccessToken: false, + authenticationAction: ( + authenticationAction!, + existingUserAccessPolicy, + existingUserAccessPolicy.authenticateReason( + user: userState + ) + ), + evaluatedPolicyMap: .init(action: processEvaluatedPolicy) + ) + } + + Button(L10n.replace) { + viewModel.saveExisting( + user: existingUser, + replaceForAccessToken: true, + authenticationAction: ( + authenticationAction!, + existingUserAccessPolicy, + existingUserAccessPolicy.authenticateReason( + user: userState + ) + ), + evaluatedPolicyMap: .init(action: processEvaluatedPolicy) + ) + } + + Button(L10n.dismiss, role: .cancel) + } message: { existingUser in + Text(L10n.duplicateUserSaved(existingUser.state.state.username)) + } + .errorMessage($viewModel.error) + } +} diff --git a/Shared/jellyfloodstore/StoredValue 3/StoredValue.swift b/Shared/jellyfloodstore/StoredValue 3/StoredValue.swift new file mode 100644 index 00000000..5b5128aa --- /dev/null +++ b/Shared/jellyfloodstore/StoredValue 3/StoredValue.swift @@ -0,0 +1,216 @@ +// +// 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 Combine +import CoreStore +import Factory +import Foundation +import Logging +import SwiftUI + +// TODO: typealias to `Setting`? +// - introduce `UserSetting` and `ServerSetting` +// that automatically namespace + +/// A property wrapper for a stored `AnyData` object. +@propertyWrapper +struct StoredValue: DynamicProperty { + + @ObservedObject + private var observable: Observable + + let key: StoredValues.Key + + var projectedValue: Binding { + $observable.value + } + + var wrappedValue: Value { + get { + observable.value + } + nonmutating set { + observable.value = newValue + } + } + + init(_ key: StoredValues.Key) { + self.key = key + self.observable = .init(key: key) + } + + mutating func update() { + _observable.update() + } +} + +extension StoredValue { + + final class Observable: ObservableObject { + + let key: StoredValues.Key + let objectWillChange = ObservableObjectPublisher() + + private let logger = Logger.swiftfin() + private var objectPublisher: ObjectPublisher? + private var shouldListenToPublish: Bool = true + + var value: Value { + get { + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return key.defaultValue() } + + let fetchedValue: Value? = try? AnyStoredData.fetch( + key.name, + ownerID: key.ownerID, + domain: key.domain + ) + + return fetchedValue ?? key.defaultValue() + } + set { + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return } + shouldListenToPublish = false + + objectWillChange.send() + + try? AnyStoredData.store( + value: newValue, + key: key.name, + ownerID: key.ownerID, + domain: key.domain ?? "" + ) + + shouldListenToPublish = true + } + } + + init(key: StoredValues.Key) { + self.key = key + self.objectPublisher = makeObjectPublisher() + } + + private func makeObjectPublisher() -> ObjectPublisher? { + + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return nil } + + let domain = key.domain ?? "none" + + let ownerFilter: Where = Where(\.$ownerID == key.ownerID) + let keyFilter: Where = Where(\.$key == key.name) + let domainFilter: Where = Where(\.$domain == domain) + + let clause = From() + .where(ownerFilter && keyFilter && domainFilter) + + if let values = try? SwiftfinStore.dataStack.fetchAll(clause), let first = values.first { + let publisher = first.asPublisher(in: SwiftfinStore.dataStack) + + publisher.addObserver(self) { [weak self] objectPublisher in + guard self?.shouldListenToPublish ?? false else { return } + guard let data = objectPublisher.object?.data else { return } + guard let newValue = try? JSONDecoder().decode(Value.self, from: data) else { fatalError() } + + DispatchQueue.main.async { + self?.value = newValue + } + } + + return publisher + } else { + // Stored value doesn't exist but we want to observe it. + // Create default and get new publisher + + // TODO: this still store unnecessary data if never changed, + // observe if changes were made and delete on deinit + + do { + try AnyStoredData.store( + value: key.defaultValue(), + key: key.name, + ownerID: key.ownerID, + domain: key.domain + ) + } catch { + logger.error("Unable to store and create publisher for: \(key)") + + return nil + } + + return makeObjectPublisher() + } + } + } +} + +enum StoredValues { + + typealias Keys = _AnyKey + + // swiftformat:disable enumnamespaces + class _AnyKey { + typealias Key = StoredValues.Key + } + + /// A key to an `AnyData` object. + /// + /// - Important: if `name` or `ownerID` are empty, the default value + /// will always be retrieved and nothing will be set. + final class Key: _AnyKey { + + let defaultValue: () -> Value + let domain: String? + let name: String + let ownerID: String + + init( + _ name: String, + ownerID: String, + domain: String?, + default defaultValue: @autoclosure @escaping () -> Value + ) { + self.defaultValue = defaultValue + self.domain = domain + self.ownerID = ownerID + self.name = name + } + + /// Always returns the given value and does not + /// set anything to storage. + init(always: @autoclosure @escaping () -> Value) { + defaultValue = always + domain = nil + name = "" + ownerID = "" + } + } + + // TODO: find way that code isn't just copied from `Observable` above + static subscript(key: Key) -> Value { + get { + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return key.defaultValue() } + + let fetchedValue: Value? = try? AnyStoredData.fetch( + key.name, + ownerID: key.ownerID, + domain: key.domain + ) + + return fetchedValue ?? key.defaultValue() + } + set { + guard key.name.isNotEmpty, key.ownerID.isNotEmpty else { return } + + try? AnyStoredData.store( + value: newValue, + key: key.name, + ownerID: key.ownerID, + domain: key.domain ?? "" + ) + } + } +} diff --git a/Shared/jellypigstore/StoredValue/StoredValues+Server.swift b/Shared/jellyfloodstore/StoredValue 3/StoredValues+Server.swift similarity index 100% rename from Shared/jellypigstore/StoredValue/StoredValues+Server.swift rename to Shared/jellyfloodstore/StoredValue 3/StoredValues+Server.swift diff --git a/Shared/jellypigstore/StoredValue/StoredValues+Temp.swift b/Shared/jellyfloodstore/StoredValue 3/StoredValues+Temp.swift similarity index 100% rename from Shared/jellypigstore/StoredValue/StoredValues+Temp.swift rename to Shared/jellyfloodstore/StoredValue 3/StoredValues+Temp.swift diff --git a/Shared/jellyfloodstore/StoredValue 3/StoredValues+User.swift b/Shared/jellyfloodstore/StoredValue 3/StoredValues+User.swift new file mode 100644 index 00000000..5492f3a1 --- /dev/null +++ b/Shared/jellyfloodstore/StoredValue 3/StoredValues+User.swift @@ -0,0 +1,229 @@ +// +// 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 Foundation +import JellyfinAPI + +// TODO: also have matching properties on `UserState` that get/set values +// TODO: cleanup/organize + +// MARK: keys + +extension StoredValues.Keys { + + /// Construct a key where `ownerID` is the id of the user in the + /// current user session, or always returns the default if there + /// isn't a current session user. + static func CurrentUserKey( + _ name: String?, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name, let currentUser = Container.shared.currentUserSession()?.user else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: currentUser.id, + domain: domain, + default: defaultValue + ) + } + + static func UserKey( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: ownerID, + domain: domain, + default: defaultValue + ) + } + + static func UserKey(always: Value) -> Key { + Key(always: always) + } +} + +// MARK: values + +extension StoredValues.Keys { + + enum User { + + // Doesn't use `CurrentUserKey` because data may be + // retrieved and stored without a user session + static func accessPolicy(id: String) -> Key { + UserKey( + "accessPolicy", + ownerID: id, + domain: "accessPolicy", + default: .none + ) + } + + // Doesn't use `CurrentUserKey` because data may be + // retrieved and stored without a user session + static func data(id: String) -> Key { + UserKey( + "userData", + ownerID: id, + domain: "userData", + default: .init() + ) + } + + static var accessPolicy: Key { + CurrentUserKey( + "currentUserAccessPolicy", + domain: "currentUserAccessPolicy", + default: .none + ) + } + + static func libraryDisplayType(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "setting-libraryDisplayType", + default: Defaults[.Customization.Library.displayType] + ) + } + + static func libraryListColumnCount(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "setting-libraryListColumnCount", + default: Defaults[.Customization.Library.listColumnCount] + ) + } + + static func libraryPosterType(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "setting-libraryPosterType", + default: Defaults[.Customization.Library.posterType] + ) + } + + // TODO: for now, only used for `sortBy` and `sortOrder`. Need to come up with + // rules for how stored filters work with libraries that should init + // with non-default filters (atow ex: favorites) + static func libraryFilters(parentID: String?) -> Key { + CurrentUserKey( + parentID, + domain: "setting-libraryFilters", + default: ItemFilterCollection.default + ) + } + + static func pinHint(id: String) -> Key { + UserKey( + "pinHint", + ownerID: id, + domain: "pinHint", + default: "" + ) + } + + static var customDeviceProfiles: Key<[CustomDeviceProfile]> { + CurrentUserKey( + "customDeviceProfiles", + domain: "customDeviceProfiles", + default: [] + ) + } + + static var enableItemEditing: Key { + CurrentUserKey( + "enableItemEditing", + domain: "enableItemEditing", + default: false + ) + } + + static var enableItemDeletion: Key { + CurrentUserKey( + "enableItemDeletion", + domain: "enableItemDeletion", + default: false + ) + } + + static var enableCollectionManagement: Key { + CurrentUserKey( + "enableCollectionManagement", + domain: "enableCollectionManagement", + default: false + ) + } + + static var enabledTrailers: Key { + CurrentUserKey( + "enabledTrailers", + domain: "enabledTrailers", + default: .all + ) + } + + static var itemViewAttributes: Key<[ItemViewAttribute]> { + CurrentUserKey( + "itemViewAttributes", + domain: "itemViewAttributes", + default: ItemViewAttribute.allCases + ) + } + + static var previewImageScrubbing: Key { + CurrentUserKey( + "previewImageScrubbing", + domain: "previewImageScrubbing", + default: .trickplay(fallbackToChapters: false) + ) + } + } +} + +// TODO: chapters fallback +enum PreviewImageScrubbingOption: CaseIterable, Displayable, Hashable, Storable { + + case trickplay(fallbackToChapters: Bool = true) + case chapters + case disabled + + var displayTitle: String { + switch self { + case .trickplay: "Trickplay" + case .disabled: L10n.disabled + case .chapters: "Chapters" + } + } + + // TODO: enhance full screen determination + // - allow checking against image size? + var supportsFullscreen: Bool { + switch self { + case .trickplay: true + case .disabled, .chapters: false + } + } + + static var allCases: [PreviewImageScrubbingOption] { + [.trickplay(), .chapters, .disabled] + } +} diff --git a/Shared/jellypigstore/StoredValue/StoredValue.swift b/Shared/jellyfloodstore/StoredValue/StoredValue.swift similarity index 100% rename from Shared/jellypigstore/StoredValue/StoredValue.swift rename to Shared/jellyfloodstore/StoredValue/StoredValue.swift diff --git a/Shared/jellyfloodstore/StoredValue/StoredValues+Server.swift b/Shared/jellyfloodstore/StoredValue/StoredValues+Server.swift new file mode 100644 index 00000000..f5d6e09b --- /dev/null +++ b/Shared/jellyfloodstore/StoredValue/StoredValues+Server.swift @@ -0,0 +1,58 @@ +// +// 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 Foundation +import JellyfinAPI + +// TODO: also have matching properties on `ServerState` that get/set values + +// MARK: keys + +extension StoredValues.Keys { + + static func ServerKey( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: ownerID, + domain: domain, + default: defaultValue + ) + } + + static func ServerKey(always: Value) -> Key { + Key(always: always) + } +} + +// MARK: values + +extension StoredValues.Keys { + + enum Server { + + static func publicInfo(id: String) -> Key { + ServerKey( + "publicInfo", + ownerID: id, + domain: "publicInfo", + default: .init() + ) + } + } +} diff --git a/Shared/jellyfloodstore/StoredValue/StoredValues+Temp.swift b/Shared/jellyfloodstore/StoredValue/StoredValues+Temp.swift new file mode 100644 index 00000000..29c5ed26 --- /dev/null +++ b/Shared/jellyfloodstore/StoredValue/StoredValues+Temp.swift @@ -0,0 +1,73 @@ +// +// 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 Foundation +import JellyfinAPI + +// Note: Temporary values to avoid refactoring or +// reduce complexity at local sites. +// +// Values can be cleaned up at any time and are +// meant to have a short lifetime. + +extension StoredValues.Keys { + + static func TempKey( + _ name: String?, + ownerID: String, + domain: String, + default defaultValue: Value + ) -> Key { + guard let name else { + return Key(always: defaultValue) + } + + return Key( + name, + ownerID: ownerID, + domain: domain, + default: defaultValue + ) + } +} + +// MARK: values + +extension StoredValues.Keys { + + enum Temp { + + static let userAccessPolicy: Key = TempKey( + "userSignInPolicy", + ownerID: "temporary", + domain: "userSignInPolicy", + default: .none + ) + + static let userLocalPin: Key = TempKey( + "userLocalPin", + ownerID: "temporary", + domain: "userLocalPin", + default: "" + ) + + static let userLocalPinHint: Key = TempKey( + "userLocalPinHint", + ownerID: "temporary", + domain: "userLocalPinHint", + default: "" + ) + + static let userData: Key = TempKey( + "tempUserData", + ownerID: "temporary", + domain: "tempUserData", + default: .init() + ) + } +} diff --git a/Shared/jellypigstore/StoredValue/StoredValues+User.swift b/Shared/jellyfloodstore/StoredValue/StoredValues+User.swift similarity index 100% rename from Shared/jellypigstore/StoredValue/StoredValues+User.swift rename to Shared/jellyfloodstore/StoredValue/StoredValues+User.swift diff --git a/Shared/jellypigstore/V1Schema/V1ServerModel.swift b/Shared/jellyfloodstore/V1Schema 3/V1ServerModel.swift similarity index 100% rename from Shared/jellypigstore/V1Schema/V1ServerModel.swift rename to Shared/jellyfloodstore/V1Schema 3/V1ServerModel.swift diff --git a/Shared/jellypigstore/V1Schema/V1UserModel.swift b/Shared/jellyfloodstore/V1Schema 3/V1UserModel.swift similarity index 100% rename from Shared/jellypigstore/V1Schema/V1UserModel.swift rename to Shared/jellyfloodstore/V1Schema 3/V1UserModel.swift diff --git a/Shared/jellypigstore/V1Schema/jellypigstore+V1.swift b/Shared/jellyfloodstore/V1Schema 3/jellypigstore+V1.swift similarity index 100% rename from Shared/jellypigstore/V1Schema/jellypigstore+V1.swift rename to Shared/jellyfloodstore/V1Schema 3/jellypigstore+V1.swift diff --git a/Shared/jellyfloodstore/V1Schema/V1ServerModel.swift b/Shared/jellyfloodstore/V1Schema/V1ServerModel.swift new file mode 100644 index 00000000..d89c90d3 --- /dev/null +++ b/Shared/jellyfloodstore/V1Schema/V1ServerModel.swift @@ -0,0 +1,47 @@ +// +// 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 CoreStore +import Foundation + +extension SwiftfinStore.V1 { + + final class StoredServer: CoreStoreObject { + + @Field.Coded("urls", coder: FieldCoders.Json.self) + var urls: Set = [] + + @Field.Stored("currentURL") + var currentURL: URL = .init(string: "/")! + + @Field.Stored("name") + var name: String = "" + + @Field.Stored("id") + var id: String = "" + + @Field.Stored("os") + var os: String = "" + + @Field.Stored("version") + var version: String = "" + + @Field.Relationship("users", inverse: \StoredUser.$server) + var users: Set + + var state: ServerState { + .init( + urls: urls, + currentURL: currentURL, + name: name, + id: id, + usersIDs: users.map(\.id) + ) + } + } +} diff --git a/Shared/jellyfloodstore/V1Schema/V1UserModel.swift b/Shared/jellyfloodstore/V1Schema/V1UserModel.swift new file mode 100644 index 00000000..78749714 --- /dev/null +++ b/Shared/jellyfloodstore/V1Schema/V1UserModel.swift @@ -0,0 +1,40 @@ +// +// 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 CoreStore +import Foundation + +extension SwiftfinStore.V1 { + + final class StoredUser: CoreStoreObject { + + @Field.Stored("accessToken") + var accessToken: String = "" + + @Field.Stored("username") + var username: String = "" + + @Field.Stored("id") + var id: String = "" + + @Field.Stored("appleTVID") + var appleTVID: String = "" + + @Field.Relationship("server") + var server: StoredServer? + + var state: UserState { + guard let server = server else { fatalError("No server associated with user") } + return .init( + id: id, + serverID: server.id, + username: username + ) + } + } +} diff --git a/Shared/jellyfloodstore/V1Schema/jellyfloodstore+V1.swift b/Shared/jellyfloodstore/V1Schema/jellyfloodstore+V1.swift new file mode 100644 index 00000000..3a878ca4 --- /dev/null +++ b/Shared/jellyfloodstore/V1Schema/jellyfloodstore+V1.swift @@ -0,0 +1,35 @@ +// +// 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 CoreStore +import Foundation + +extension SwiftfinStore.V1 { + + static let schema = CoreStoreSchema( + modelVersion: "V1", + entities: [ + Entity("Server"), + Entity("User"), + ], + versionLock: [ + "Server": [ + 0x4E8_8201_635C_2BB5, + 0x7A7_85D8_A65D_177C, + 0x3FE6_7B5B_D402_6EEE, + 0x8893_16D4_188E_B136, + ], + "User": [ + 0x1001_44F1_4D4D_5A31, + 0x828F_7943_7D0B_4C03, + 0x3824_5761_B815_D61A, + 0x3C1D_BF68_E42B_1DA6, + ], + ] + ) +} diff --git a/Shared/jellypigstore/V2Schema/V2AnyData.swift b/Shared/jellyfloodstore/V2Schema 3/V2AnyData.swift similarity index 100% rename from Shared/jellypigstore/V2Schema/V2AnyData.swift rename to Shared/jellyfloodstore/V2Schema 3/V2AnyData.swift diff --git a/Shared/jellypigstore/V2Schema/V2ServerModel.swift b/Shared/jellyfloodstore/V2Schema 3/V2ServerModel.swift similarity index 100% rename from Shared/jellypigstore/V2Schema/V2ServerModel.swift rename to Shared/jellyfloodstore/V2Schema 3/V2ServerModel.swift diff --git a/Shared/jellyfloodstore/V2Schema 3/V2UserModel.swift b/Shared/jellyfloodstore/V2Schema 3/V2UserModel.swift new file mode 100644 index 00000000..45098bb3 --- /dev/null +++ b/Shared/jellyfloodstore/V2Schema 3/V2UserModel.swift @@ -0,0 +1,35 @@ +// +// 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 CoreStore +import Foundation +import UIKit + +extension SwiftfinStore.V2 { + + final class StoredUser: CoreStoreObject { + + @Field.Stored("username") + var username: String = "" + + @Field.Stored("id") + var id: String = "" + + @Field.Relationship("server") + var server: StoredServer? + + var state: UserState { + guard let server = server else { fatalError("No server associated with user") } + return .init( + id: id, + serverID: server.id, + username: username + ) + } + } +} diff --git a/Shared/jellypigstore/V2Schema/jellypigstore+V2.swift b/Shared/jellyfloodstore/V2Schema 3/jellypigstore+V2.swift similarity index 100% rename from Shared/jellypigstore/V2Schema/jellypigstore+V2.swift rename to Shared/jellyfloodstore/V2Schema 3/jellypigstore+V2.swift diff --git a/Shared/jellyfloodstore/V2Schema/V2AnyData.swift b/Shared/jellyfloodstore/V2Schema/V2AnyData.swift new file mode 100644 index 00000000..7df349e4 --- /dev/null +++ b/Shared/jellyfloodstore/V2Schema/V2AnyData.swift @@ -0,0 +1,181 @@ +// +// 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 Combine +import CoreStore +import Defaults +import Factory +import Foundation +import SwiftUI + +extension SwiftfinStore.V2 { + + /// Used to store arbitrary data with a `name` and `ownerID`. + /// + /// Essentially just a bag-of-bytes model like UserDefaults, but for + /// storing larger objects or arbitrary collection elements. + /// + /// Relationships generally take the form below, where `ownerID` is like + /// an object, `domain`s are property names, and `key`s are values within + /// the `domain`. An instance where `domain == key` is like a single-value + /// property while a `domain` with many `keys` is like a dictionary. + /// + /// ownerID + /// - domain + /// - key(s) + /// - domain + /// - key(s) + /// + /// This can be useful to not require migrations on model objects for new + /// "properties". + final class AnyData: CoreStoreObject { + + @Field.Stored("data") + var data: Data? + + @Field.Stored("domain") + var domain: String = "" + + @Field.Stored("key") + var key: String = "" + + @Field.Stored("ownerID") + var ownerID: String = "" + } +} + +extension AnyStoredData { + + /// Note: if `domain == nil`, will default to "none" to avoid local typing issues. + static func fetch(_ key: String, ownerID: String, domain: String? = nil) throws -> Value? { + + let domain = domain ?? "none" + + let ownerFilter: Where = Where(\.$ownerID == ownerID) + let keyFilter: Where = Where(\.$key == key) + let domainFilter: Where = Where(\.$domain == domain) + + let clause = From() + .where(ownerFilter && keyFilter && domainFilter) + + let values = try SwiftfinStore.dataStack + .fetchAll( + clause + ) + .compactMap(\.data) + .compactMap { + try JSONDecoder().decode(Value.self, from: $0) + } + + assert(values.count < 2, "More than one stored object for same name, id, and domain!") + + return values.first + } + + /// Note: if `domain == nil`, will default to "none" to avoid local typing issues. + static func store(value: Value, key: String, ownerID: String, domain: String? = nil) throws { + + let domain = domain ?? "none" + + let ownerFilter: Where = Where(\.$ownerID == ownerID) + let keyFilter: Where = Where(\.$key == key) + let domainFilter: Where = Where(\.$domain == domain) + + let clause = From() + .where(ownerFilter && keyFilter && domainFilter) + + try SwiftfinStore.dataStack.perform { transaction in + let existing = try transaction.fetchAll(clause) + + assert(existing.count < 2, "More than one stored object for same name, id, and domain!") + + let encodedData = try JSONEncoder().encode(value) + + if let existingObject = existing.first { + let edit = transaction.edit(existingObject) + edit?.data = encodedData + } else { + let newData = transaction.create(Into()) + + newData.data = encodedData + newData.domain = domain + newData.ownerID = ownerID + newData.key = key + } + } + } + + /// Creates a fetch clause to be used within local transactions + static func fetchClause(ownerID: String) -> FetchChainBuilder { + From() + .where(\.$ownerID == ownerID) + } + + /// Creates a fetch clause to be used within local transactions + /// + /// Note: if `domain == nil`, will default to "none" + static func fetchClause(ownerID: String, domain: String? = nil) throws -> FetchChainBuilder { + let domain = domain ?? "none" + + return From() + .where(\.$ownerID == ownerID && \.$domain == domain) + } + + /// Creates a fetch clause to be used within local transactions + /// + /// Note: if `domain == nil`, will default to "none" + static func fetchClause(key: String, ownerID: String, domain: String? = nil) throws -> FetchChainBuilder { + let domain = domain ?? "none" + + let ownerFilter: Where = Where(\.$ownerID == ownerID) + let keyFilter: Where = Where(\.$key == key) + let domainFilter: Where = Where(\.$domain == domain) + + return From() + .where(ownerFilter && keyFilter && domainFilter) + } + + /// Delete all data with the given `ownerID` + /// + /// Note: if performing deletion with another transaction, use `fetchClause` + /// instead to delete within the other transaction + static func deleteAll(ownerID: String) throws { + try SwiftfinStore.dataStack.perform { transaction in + let values = try transaction.fetchAll(fetchClause(ownerID: ownerID)) + + transaction.delete(values) + } + } + + /// Delete all data with the given `ownerID` and `domain` + /// + /// Note: if performing deletion with another transaction, use `fetchClause` + /// instead to delete within the other transaction + /// Note: if `domain == nil`, will default to "none" + static func deleteAll(ownerID: String, domain: String? = nil) throws { + try SwiftfinStore.dataStack.perform { transaction in + let values = try transaction.fetchAll(fetchClause(ownerID: ownerID, domain: domain)) + + transaction.delete(values) + } + } + + /// Delete all data given `key`, `ownerID`, and `domain`. + /// + /// + /// Note: if performing deletion with another transaction, use `fetchClause` + /// instead to delete within the other transaction + /// Note: if `domain == nil`, will default to "none" + static func delete(key: String, ownerID: String, domain: String? = nil) throws { + try SwiftfinStore.dataStack.perform { transaction in + let values = try transaction.fetchAll(fetchClause(key: key, ownerID: ownerID, domain: domain)) + + transaction.delete(values) + } + } +} diff --git a/Shared/jellyfloodstore/V2Schema/V2ServerModel.swift b/Shared/jellyfloodstore/V2Schema/V2ServerModel.swift new file mode 100644 index 00000000..e57ba0d6 --- /dev/null +++ b/Shared/jellyfloodstore/V2Schema/V2ServerModel.swift @@ -0,0 +1,43 @@ +// +// 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 CoreStore +import Foundation + +// TODO: complete and make migration + +extension SwiftfinStore.V2 { + + final class StoredServer: CoreStoreObject { + + @Field.Coded("urls", coder: FieldCoders.Json.self) + var urls: Set = [] + + @Field.Stored("currentURL") + var currentURL: URL = .init(string: "/")! + + @Field.Stored("name") + var name: String = "" + + @Field.Stored("id") + var id: String = "" + + @Field.Relationship("users", inverse: \StoredUser.$server) + var users: Set + + var state: ServerState { + .init( + urls: urls, + currentURL: currentURL, + name: name, + id: id, + usersIDs: users.map(\.id) + ) + } + } +} diff --git a/Shared/jellypigstore/V2Schema/V2UserModel.swift b/Shared/jellyfloodstore/V2Schema/V2UserModel.swift similarity index 100% rename from Shared/jellypigstore/V2Schema/V2UserModel.swift rename to Shared/jellyfloodstore/V2Schema/V2UserModel.swift diff --git a/Shared/jellyfloodstore/V2Schema/jellyfloodstore+V2.swift b/Shared/jellyfloodstore/V2Schema/jellyfloodstore+V2.swift new file mode 100644 index 00000000..d6843832 --- /dev/null +++ b/Shared/jellyfloodstore/V2Schema/jellyfloodstore+V2.swift @@ -0,0 +1,29 @@ +// +// 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 CoreStore +import Foundation + +// TODO: complete and make migration + +extension SwiftfinStore.V2 { + + static let schema = CoreStoreSchema( + modelVersion: "V2", + entities: [ + Entity("Server"), + Entity("User"), + Entity("AnyData"), + ], + versionLock: [ + "AnyData": [0x749D_39C2_219D_4918, 0x9281_539F_1DFB_63E1, 0x293F_D0B7_B64C_E984, 0x8F2F_91F2_33EA_8EB5], + "Server": [0xC831_8BCA_3734_8B36, 0x78F9_E383_4EC4_0409, 0xC32D_7C44_D347_6825, 0x8593_766E_CEC6_0CFD], + "User": [0xAE4F_5BDB_1E41_8019, 0x7E5D_7722_D051_7C12, 0x3867_AC59_9F91_A895, 0x6CB9_F896_6ED4_4944], + ] + ) +} diff --git a/Shared/jellyfloodstore/jellyfloodstore 2.swift b/Shared/jellyfloodstore/jellyfloodstore 2.swift new file mode 100644 index 00000000..14f973f6 --- /dev/null +++ b/Shared/jellyfloodstore/jellyfloodstore 2.swift @@ -0,0 +1,80 @@ +// +// 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 CoreStore +import Factory +import Foundation +import JellyfinAPI +import Logging + +typealias AnyStoredData = SwiftfinStore.V2.AnyData +typealias ServerModel = SwiftfinStore.V2.StoredServer +typealias UserModel = SwiftfinStore.V2.StoredUser + +typealias ServerState = SwiftfinStore.State.Server +typealias UserState = SwiftfinStore.State.User + +// MARK: Namespaces + +extension Container { + var dataStore: Factory { self { SwiftfinStore.dataStack }.singleton } +} + +enum SwiftfinStore { + + /// Namespace for V1 objects + enum V1 {} + + /// Namespace for V2 objects + enum V2 {} + + /// Namespace for state objects + enum State {} + + private static let logger = Logger.swiftfin() +} + +// MARK: dataStack + +// TODO: cleanup + +extension SwiftfinStore { + + static let dataStack: DataStack = { + DataStack( + V1.schema, + V2.schema, + migrationChain: ["V1", "V2"] + ) + }() + + private static let storage: SQLiteStore = { + SQLiteStore( + fileName: "Swiftfin.sqlite", + migrationMappingProviders: [Mappings.userV1_V2] + ) + }() + + static func requiresMigration() throws -> Bool { + try dataStack.requiredMigrationsForStorage(storage).isNotEmpty + } + + static func setupDataStack() async throws { + try await withCheckedThrowingContinuation { continuation in + _ = dataStack.addStorage(storage) { result in + switch result { + case .success: + continuation.resume() + case let .failure(error): + logger.error("Failed creating datastack with: \(error.localizedDescription)") + continuation.resume(throwing: JellyfinAPIError("Failed creating datastack with: \(error.localizedDescription)")) + } + } + } + } +} diff --git a/Shared/jellypigstore/jellypigstore+Mappings.swift b/Shared/jellyfloodstore/jellyfloodstore+Mappings 2.swift similarity index 100% rename from Shared/jellypigstore/jellypigstore+Mappings.swift rename to Shared/jellyfloodstore/jellyfloodstore+Mappings 2.swift diff --git a/Shared/jellyfloodstore/jellyfloodstore+Mappings.swift b/Shared/jellyfloodstore/jellyfloodstore+Mappings.swift new file mode 100644 index 00000000..8f9b01ba --- /dev/null +++ b/Shared/jellyfloodstore/jellyfloodstore+Mappings.swift @@ -0,0 +1,53 @@ +// +// 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 CoreStore +import Factory +import Foundation +import KeychainSwift + +extension SwiftfinStore { + enum Mappings {} +} + +extension SwiftfinStore.Mappings { + + // MARK: User V1 to V2 + + // V1 users had access token stored in Core Data. + // Move to the Keychain. + + static let userV1_V2 = { + CustomSchemaMappingProvider( + from: "V1", + to: "V2", + entityMappings: [ + .transformEntity( + sourceEntity: "User", + destinationEntity: "User", + transformer: { sourceObject, createDestinationObject in + + // move access token to Keychain + if let id = sourceObject["id"] as? String, let accessToken = sourceObject["accessToken"] as? String { + Container.shared.keychainService().set(accessToken, forKey: "\(id)-accessToken") + } else { + fatalError("wtf") + } + + let destinationObject = createDestinationObject() + destinationObject.enumerateAttributes { attribute, sourceAttribute in + if let sourceAttribute { + destinationObject[attribute] = sourceObject[attribute] + } + } + } + ), + ] + ) + }() +} diff --git a/Shared/jellyfloodstore/jellyfloodstore+ServerState 2.swift b/Shared/jellyfloodstore/jellyfloodstore+ServerState 2.swift new file mode 100644 index 00000000..3684274a --- /dev/null +++ b/Shared/jellyfloodstore/jellyfloodstore+ServerState 2.swift @@ -0,0 +1,109 @@ +// +// 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 CoreStore +import Factory +import Foundation +import JellyfinAPI +import Pulse + +extension SwiftfinStore.State { + + struct Server: Hashable, Identifiable { + + let urls: Set + let currentURL: URL + let name: String + let id: String + let userIDs: [String] + + init( + urls: Set, + currentURL: URL, + name: String, + id: String, + usersIDs: [String] + ) { + self.urls = urls + self.currentURL = currentURL + self.name = name + self.id = id + self.userIDs = usersIDs + } + + /// - Note: Since this is created from a server, it does not + /// have a user access token. + var client: JellyfinClient { + JellyfinClient( + configuration: .swiftfinConfiguration(url: currentURL), + sessionConfiguration: .swiftfin, + sessionDelegate: URLSessionProxyDelegate(logger: NetworkLogger.swiftfin()) + ) + } + } +} + +extension ServerState { + + /// Deletes the model that this state represents and + /// all settings from `StoredValues`. + func delete() throws { + try SwiftfinStore.dataStack.perform { transaction in + guard let storedServer = try transaction.fetchOne(From().where(\.$id == id)) else { + throw JellyfinAPIError("Unable to find server to delete") + } + + let storedDataClause = AnyStoredData.fetchClause(ownerID: id) + let storedData = try transaction.fetchAll(storedDataClause) + + transaction.delete(storedData) + transaction.delete(storedServer) + } + } + + func getPublicSystemInfo() async throws -> PublicSystemInfo { + + let request = Paths.getPublicSystemInfo + let response = try await client.send(request) + + return response.value + } + + var splashScreenImageSource: ImageSource { + let request = Paths.getSplashscreen() + return ImageSource(url: client.fullURL(with: request)) + } + + @MainActor + func updateServerInfo() async throws { + guard let server = try? SwiftfinStore.dataStack.fetchOne( + From().where(Where(\.$id == id)) + ) else { return } + + let publicInfo = try await getPublicSystemInfo() + + try SwiftfinStore.dataStack.perform { transaction in + guard let newServer = transaction.edit(server) else { return } + + newServer.name = publicInfo.serverName ?? newServer.name + newServer.id = publicInfo.id ?? newServer.id + } + + StoredValues[.Server.publicInfo(id: server.id)] = publicInfo + } + + var isVersionCompatible: Bool { + let publicInfo = StoredValues[.Server.publicInfo(id: self.id)] + + if let version = publicInfo.version { + return JellyfinClient.Version(stringLiteral: version).majorMinor >= JellyfinClient.sdkVersion.majorMinor + } else { + return false + } + } +} diff --git a/Shared/jellypigstore/jellypigstore+ServerState.swift b/Shared/jellyfloodstore/jellyfloodstore+ServerState.swift similarity index 100% rename from Shared/jellypigstore/jellypigstore+ServerState.swift rename to Shared/jellyfloodstore/jellyfloodstore+ServerState.swift diff --git a/Shared/jellyfloodstore/jellyfloodstore+UserState 2.swift b/Shared/jellyfloodstore/jellyfloodstore+UserState 2.swift new file mode 100644 index 00000000..374cb7fc --- /dev/null +++ b/Shared/jellyfloodstore/jellyfloodstore+UserState 2.swift @@ -0,0 +1,178 @@ +// +// 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 CoreStore +import Factory +import Foundation +import JellyfinAPI +import KeychainSwift +import Pulse +import UIKit + +// Note: it is kind of backwards to have a "state" object with a mix of +// non-mutable and "mutable" values, but it just works. + +extension SwiftfinStore.State { + + struct User: Hashable, Identifiable { + + let id: String + let serverID: String + let username: String + + init( + id: String, + serverID: String, + username: String + ) { + self.id = id + self.serverID = serverID + self.username = username + } + } +} + +extension UserState { + + typealias Key = StoredValues.Key + + var accessToken: String { + get { + guard let accessToken = Container.shared.keychainService().get("\(id)-accessToken") else { + assertionFailure("access token missing in keychain") + return "" + } + + return accessToken + } + nonmutating set { + Container.shared.keychainService().set(newValue, forKey: "\(id)-accessToken") + } + } + + var data: UserDto { + get { + StoredValues[.User.data(id: id)] + } + nonmutating set { + StoredValues[.User.data(id: id)] = newValue + } + } + + var permissions: UserPermissions { + UserPermissions(data.policy) + } + + var pin: String { + get { + guard let pin = Container.shared.keychainService().get("\(id)-pin") else { + assertionFailure("pin missing in keychain") + return "" + } + + return pin + } + nonmutating set { + Container.shared.keychainService().set(newValue, forKey: "\(id)-pin") + } + } + + var pinHint: String { + get { + StoredValues[.User.pinHint(id: id)] + } + nonmutating set { + StoredValues[.User.pinHint(id: id)] = newValue + } + } + + var accessPolicy: UserAccessPolicy { + get { + StoredValues[.User.accessPolicy(id: id)] + } + nonmutating set { + StoredValues[.User.accessPolicy(id: id)] = newValue + } + } +} + +extension UserState { + + /// Deletes the model that this state represents and + /// all settings from `Defaults` `Keychain`, and `StoredValues` + func delete() throws { + try SwiftfinStore.dataStack.perform { transaction in + guard let storedUser = try transaction.fetchOne(From().where(\.$id == id)) else { + throw JellyfinAPIError("Unable to find user to delete") + } + + let storedDataClause = AnyStoredData.fetchClause(ownerID: id) + let storedData = try transaction.fetchAll(storedDataClause) + + transaction.delete(storedUser) + transaction.delete(storedData) + } + + UserDefaults.userSuite(id: id).removeAll() + + let keychain = Container.shared.keychainService() + keychain.delete("\(id)-pin") + } + + /// Deletes user settings from `UserDefaults` and `StoredValues` + /// + /// Note: if performing deletion with another transaction, use + /// `AnyStoredData.fetchClause` instead within that transaction + /// and delete `Defaults` manually + func deleteSettings() throws { + try SwiftfinStore.dataStack.perform { transaction in + let userData = try transaction.fetchAll( + From() + .where(combineByAnd: Where(\.$ownerID == id), Where("%K BEGINSWITH %@", "domain", "setting")) + ) + + transaction.delete(userData) + } + + UserDefaults.userSuite(id: id).removeAll() + } + + /// Must pass the server to create a JellyfinClient + /// with an access token + func getUserData(server: ServerState) async throws -> UserDto { + let client = JellyfinClient( + configuration: .swiftfinConfiguration(url: server.currentURL), + sessionConfiguration: .swiftfin, + sessionDelegate: URLSessionProxyDelegate(logger: NetworkLogger.swiftfin()), + accessToken: accessToken + ) + + let request = Paths.getCurrentUser + let response = try await client.send(request) + + return response.value + } + + // we will always crop to a square, so just use width + func profileImageSource( + client: JellyfinClient, + maxWidth: CGFloat? = nil + ) -> ImageSource { + let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) + + let parameters = Paths.GetUserImageParameters( + userID: id, + maxWidth: scaleWidth + ) + let request = Paths.getUserImage(parameters: parameters) + + let profileImageURL = client.fullURL(with: request) + + return ImageSource(url: profileImageURL) + } +} diff --git a/Shared/jellypigstore/jellypigstore+UserState.swift b/Shared/jellyfloodstore/jellyfloodstore+UserState.swift similarity index 100% rename from Shared/jellypigstore/jellypigstore+UserState.swift rename to Shared/jellyfloodstore/jellyfloodstore+UserState.swift diff --git a/Shared/jellypigstore/jellypigstore.swift b/Shared/jellyfloodstore/jellyfloodstore.swift similarity index 100% rename from Shared/jellypigstore/jellypigstore.swift rename to Shared/jellyfloodstore/jellyfloodstore.swift diff --git a/jellypig tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin tvOS/App/PreferenceUIHosting 2/PreferenceUIHostingController.swift similarity index 100% rename from jellypig tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift rename to Swiftfin tvOS/App/PreferenceUIHosting 2/PreferenceUIHostingController.swift diff --git a/jellypig tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin tvOS/App/PreferenceUIHosting 2/PreferenceUIHostingSwizzling.swift similarity index 100% rename from jellypig tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift rename to Swiftfin tvOS/App/PreferenceUIHosting 2/PreferenceUIHostingSwizzling.swift diff --git a/Swiftfin tvOS/App/SwiftfinApp 2.swift b/Swiftfin tvOS/App/SwiftfinApp 2.swift new file mode 100644 index 00000000..fca7516d --- /dev/null +++ b/Swiftfin tvOS/App/SwiftfinApp 2.swift @@ -0,0 +1,81 @@ +// +// 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 CoreStore +import Defaults +import Factory +import Logging +import Nuke +import Pulse +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + init() { + + // Logging + LoggingSystem.bootstrap { label in + + let handlers: [any LogHandler] = [PersistentLogHandler(label: label)] + #if DEBUG + .appending(SwiftfinConsoleHandler()) + #endif + + var multiplexHandler = MultiplexLogHandler(handlers) + multiplexHandler.logLevel = .trace + return multiplexHandler + } + + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.posters + + // UIKit + + UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] + + // don't keep last user id + if Defaults[.signOutOnClose] { + Defaults[.lastSignedInUserID] = .signedOut + } + } + + var body: some Scene { + WindowGroup { + RootView() + .onNotification(.applicationDidEnterBackground) { + Defaults[.backgroundTimeStamp] = Date.now + } + .onNotification(.applicationWillEnterForeground) { + // TODO: needs to check if any background playback is happening + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = .signedOut + Container.shared.currentUserSession.reset() + Notifications[.didSignOut].post() + } + } + } + } +} diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift new file mode 100644 index 00000000..fca7516d --- /dev/null +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -0,0 +1,81 @@ +// +// 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 CoreStore +import Defaults +import Factory +import Logging +import Nuke +import Pulse +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + init() { + + // Logging + LoggingSystem.bootstrap { label in + + let handlers: [any LogHandler] = [PersistentLogHandler(label: label)] + #if DEBUG + .appending(SwiftfinConsoleHandler()) + #endif + + var multiplexHandler = MultiplexLogHandler(handlers) + multiplexHandler.logLevel = .trace + return multiplexHandler + } + + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.posters + + // UIKit + + UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] + + // don't keep last user id + if Defaults[.signOutOnClose] { + Defaults[.lastSignedInUserID] = .signedOut + } + } + + var body: some Scene { + WindowGroup { + RootView() + .onNotification(.applicationDidEnterBackground) { + Defaults[.backgroundTimeStamp] = Date.now + } + .onNotification(.applicationWillEnterForeground) { + // TODO: needs to check if any background playback is happening + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = .signedOut + Container.shared.currentUserSession.reset() + Notifications[.didSignOut].post() + } + } + } + } +} diff --git a/Swiftfin tvOS/Components/CapsuleSlider 2.swift b/Swiftfin tvOS/Components/CapsuleSlider 2.swift new file mode 100644 index 00000000..3d811cd7 --- /dev/null +++ b/Swiftfin tvOS/Components/CapsuleSlider 2.swift @@ -0,0 +1,56 @@ +// +// 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 CapsuleSlider: View { + + @Binding + private var value: Value + + @FocusState + private var isFocused: Bool + + private let total: Value + private var onEditingChanged: (Bool) -> Void + + init(value: Binding, total: Value) { + self._value = value + self.total = total + self.onEditingChanged = { _ in } + } + + var body: some View { + SliderContainer( + value: $value, + total: total, + onEditingChanged: onEditingChanged + ) { + CapsuleSliderContent() + } + } +} + +extension CapsuleSlider { + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } +} + +private struct CapsuleSliderContent: SliderContentView { + + @EnvironmentObject + var sliderState: SliderContainerState + + var body: some View { + ProgressView(value: sliderState.value, total: sliderState.total) + .progressViewStyle(PlaybackProgressViewStyle(cornerStyle: .round)) + .frame(height: 30) + } +} diff --git a/Swiftfin tvOS/Components/CapsuleSlider.swift b/Swiftfin tvOS/Components/CapsuleSlider.swift new file mode 100644 index 00000000..3d811cd7 --- /dev/null +++ b/Swiftfin tvOS/Components/CapsuleSlider.swift @@ -0,0 +1,56 @@ +// +// 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 CapsuleSlider: View { + + @Binding + private var value: Value + + @FocusState + private var isFocused: Bool + + private let total: Value + private var onEditingChanged: (Bool) -> Void + + init(value: Binding, total: Value) { + self._value = value + self.total = total + self.onEditingChanged = { _ in } + } + + var body: some View { + SliderContainer( + value: $value, + total: total, + onEditingChanged: onEditingChanged + ) { + CapsuleSliderContent() + } + } +} + +extension CapsuleSlider { + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } +} + +private struct CapsuleSliderContent: SliderContentView { + + @EnvironmentObject + var sliderState: SliderContainerState + + var body: some View { + ProgressView(value: sliderState.value, total: sliderState.total) + .progressViewStyle(PlaybackProgressViewStyle(cornerStyle: .round)) + .frame(height: 30) + } +} diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView 2.swift b/Swiftfin tvOS/Components/CinematicBackgroundView 2.swift new file mode 100644 index 00000000..83cde560 --- /dev/null +++ b/Swiftfin tvOS/Components/CinematicBackgroundView 2.swift @@ -0,0 +1,61 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +struct CinematicBackgroundView: View { + + @ObservedObject + var viewModel: Proxy + + @StateObject + private var proxy: RotateContentView.Proxy = .init() + + var initialItem: (any Poster)? + + var body: some View { + RotateContentView(proxy: proxy) + .onChange(of: viewModel.currentItem) { _, newItem in + proxy.update { + ImageView(newItem?.cinematicImageSources(maxWidth: nil) ?? []) + .placeholder { _ in + Color.clear + } + .failure { + Color.clear + } + .aspectRatio(contentMode: .fill) + } + } + } + + class Proxy: ObservableObject { + + @Published + var currentItem: AnyPoster? + + private var cancellables = Set() + private var currentItemSubject = CurrentValueSubject(nil) + + init() { + currentItemSubject + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { newItem in + self.currentItem = newItem + } + .store(in: &cancellables) + } + + func select(item: some Poster) { + currentItemSubject.send(AnyPoster(item)) + } + } +} diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/CinematicBackgroundView.swift new file mode 100644 index 00000000..83cde560 --- /dev/null +++ b/Swiftfin tvOS/Components/CinematicBackgroundView.swift @@ -0,0 +1,61 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +struct CinematicBackgroundView: View { + + @ObservedObject + var viewModel: Proxy + + @StateObject + private var proxy: RotateContentView.Proxy = .init() + + var initialItem: (any Poster)? + + var body: some View { + RotateContentView(proxy: proxy) + .onChange(of: viewModel.currentItem) { _, newItem in + proxy.update { + ImageView(newItem?.cinematicImageSources(maxWidth: nil) ?? []) + .placeholder { _ in + Color.clear + } + .failure { + Color.clear + } + .aspectRatio(contentMode: .fill) + } + } + } + + class Proxy: ObservableObject { + + @Published + var currentItem: AnyPoster? + + private var cancellables = Set() + private var currentItemSubject = CurrentValueSubject(nil) + + init() { + currentItemSubject + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { newItem in + self.currentItem = newItem + } + .store(in: &cancellables) + } + + func select(item: some Poster) { + currentItemSubject.send(AnyPoster(item)) + } + } +} diff --git a/Swiftfin tvOS/Components/CinematicItemSelector 2.swift b/Swiftfin tvOS/Components/CinematicItemSelector 2.swift new file mode 100644 index 00000000..742fe433 --- /dev/null +++ b/Swiftfin tvOS/Components/CinematicItemSelector 2.swift @@ -0,0 +1,113 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +// TODO: make new protocol for cinematic view image provider +// TODO: better name + +struct CinematicItemSelector: View { + + @FocusState + private var isSectionFocused + + @FocusedValue(\.focusedPoster) + private var focusedPoster + + @StateObject + private var viewModel: CinematicBackgroundView.Proxy = .init() + + private var topContent: (Item) -> any View + private var itemContent: (Item) -> any View + private var trailingContent: () -> any View + private var onSelect: (Item) -> Void + + let items: [Item] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let focusedPoster, let focusedItem = focusedPoster._poster as? Item { + topContent(focusedItem) + .eraseToAnyView() + .id(focusedItem.hashValue) + .transition(.opacity) + } + + // TODO: fix intrinsic content sizing without frame + PosterHStack( + type: .landscape, + items: items, + action: onSelect, + label: itemContent + ) + .frame(height: 400) + } + .frame(height: UIScreen.main.bounds.height - 75, alignment: .bottomLeading) + .frame(maxWidth: .infinity) + .background(alignment: .top) { + CinematicBackgroundView( + viewModel: viewModel, + initialItem: items.first + ) + .overlay { + Color.black + .maskLinearGradient { + (location: 0.5, opacity: 0) + (location: 0.6, opacity: 0.4) + (location: 1, opacity: 1) + } + } + .frame(height: UIScreen.main.bounds.height) + .maskLinearGradient { + (location: 0.9, opacity: 1) + (location: 1, opacity: 0) + } + } + .onChange(of: focusedPoster) { + guard let focusedPoster, isSectionFocused else { return } + viewModel.select(item: focusedPoster) + } + .focusSection() + .focused($isSectionFocused) + } +} + +extension CinematicItemSelector { + + init(items: [Item]) { + self.init( + topContent: { _ in EmptyView() }, + itemContent: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in }, + items: items + ) + } +} + +extension CinematicItemSelector { + + func topContent(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.itemContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> T) -> Self { + copy(modifying: \.trailingContent, with: content) + } + + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift new file mode 100644 index 00000000..742fe433 --- /dev/null +++ b/Swiftfin tvOS/Components/CinematicItemSelector.swift @@ -0,0 +1,113 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +// TODO: make new protocol for cinematic view image provider +// TODO: better name + +struct CinematicItemSelector: View { + + @FocusState + private var isSectionFocused + + @FocusedValue(\.focusedPoster) + private var focusedPoster + + @StateObject + private var viewModel: CinematicBackgroundView.Proxy = .init() + + private var topContent: (Item) -> any View + private var itemContent: (Item) -> any View + private var trailingContent: () -> any View + private var onSelect: (Item) -> Void + + let items: [Item] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let focusedPoster, let focusedItem = focusedPoster._poster as? Item { + topContent(focusedItem) + .eraseToAnyView() + .id(focusedItem.hashValue) + .transition(.opacity) + } + + // TODO: fix intrinsic content sizing without frame + PosterHStack( + type: .landscape, + items: items, + action: onSelect, + label: itemContent + ) + .frame(height: 400) + } + .frame(height: UIScreen.main.bounds.height - 75, alignment: .bottomLeading) + .frame(maxWidth: .infinity) + .background(alignment: .top) { + CinematicBackgroundView( + viewModel: viewModel, + initialItem: items.first + ) + .overlay { + Color.black + .maskLinearGradient { + (location: 0.5, opacity: 0) + (location: 0.6, opacity: 0.4) + (location: 1, opacity: 1) + } + } + .frame(height: UIScreen.main.bounds.height) + .maskLinearGradient { + (location: 0.9, opacity: 1) + (location: 1, opacity: 0) + } + } + .onChange(of: focusedPoster) { + guard let focusedPoster, isSectionFocused else { return } + viewModel.select(item: focusedPoster) + } + .focusSection() + .focused($isSectionFocused) + } +} + +extension CinematicItemSelector { + + init(items: [Item]) { + self.init( + topContent: { _ in EmptyView() }, + itemContent: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in }, + items: items + ) + } +} + +extension CinematicItemSelector { + + func topContent(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.itemContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> T) -> Self { + copy(modifying: \.trailingContent, with: content) + } + + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Components/DotHStack 2.swift b/Swiftfin tvOS/Components/DotHStack 2.swift new file mode 100644 index 00000000..3954cd35 --- /dev/null +++ b/Swiftfin tvOS/Components/DotHStack 2.swift @@ -0,0 +1,22 @@ +// +// 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 + +func DotHStack( + padding: CGFloat = 10, + @ViewBuilder content: @escaping () -> Content +) -> some View { + SeparatorHStack { + Circle() + .frame(width: 5, height: 5) + .padding(.horizontal, 10) + } content: { + content() + } +} diff --git a/Swiftfin tvOS/Components/DotHStack.swift b/Swiftfin tvOS/Components/DotHStack.swift new file mode 100644 index 00000000..3954cd35 --- /dev/null +++ b/Swiftfin tvOS/Components/DotHStack.swift @@ -0,0 +1,22 @@ +// +// 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 + +func DotHStack( + padding: CGFloat = 10, + @ViewBuilder content: @escaping () -> Content +) -> some View { + SeparatorHStack { + Circle() + .frame(width: 5, height: 5) + .padding(.horizontal, 10) + } content: { + content() + } +} diff --git a/jellypig tvOS/Components/EnumPickerView.swift b/Swiftfin tvOS/Components/EnumPickerView 2.swift similarity index 100% rename from jellypig tvOS/Components/EnumPickerView.swift rename to Swiftfin tvOS/Components/EnumPickerView 2.swift diff --git a/Swiftfin tvOS/Components/EnumPickerView.swift b/Swiftfin tvOS/Components/EnumPickerView.swift new file mode 100644 index 00000000..87640f8a --- /dev/null +++ b/Swiftfin tvOS/Components/EnumPickerView.swift @@ -0,0 +1,60 @@ +// +// 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 EnumPickerView: View { + + @Binding + private var selection: EnumType + + private var descriptionView: () -> any View + private var title: String? + + var body: some View { + SplitFormWindowView() + .descriptionView(descriptionView) + .contentView { + Section { + ForEach(EnumType.allCases.asArray, id: \.hashValue) { item in + Button { + selection = item + } label: { + HStack { + Text(item.displayTitle) + + Spacer() + + if selection == item { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } + } +} + +extension EnumPickerView { + + init( + title: String? = nil, + selection: Binding + ) { + self.init( + selection: selection, + descriptionView: { EmptyView() }, + title: title + ) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/jellypig tvOS/Components/ErrorView.swift b/Swiftfin tvOS/Components/ErrorView 2.swift similarity index 100% rename from jellypig tvOS/Components/ErrorView.swift rename to Swiftfin tvOS/Components/ErrorView 2.swift diff --git a/Swiftfin tvOS/Components/ErrorView.swift b/Swiftfin tvOS/Components/ErrorView.swift new file mode 100644 index 00000000..d3bceda8 --- /dev/null +++ b/Swiftfin tvOS/Components/ErrorView.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView: View { + + @Default(.accentColor) + private var accentColor + + private let error: ErrorType + private var onRetry: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 150)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(minWidth: 250, maxWidth: 750) + .multilineTextAlignment(.center) + + if let onRetry { + ListRowButton(L10n.retry, action: onRetry) + .foregroundStyle(accentColor.overlayColor, accentColor) + .frame(maxWidth: 750) + } + } + } +} + +extension ErrorView { + + init(error: ErrorType) { + self.init( + error: error, + onRetry: nil + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/jellypig tvOS/Components/LandscapePosterProgressBar.swift b/Swiftfin tvOS/Components/LandscapePosterProgressBar 2.swift similarity index 100% rename from jellypig tvOS/Components/LandscapePosterProgressBar.swift rename to Swiftfin tvOS/Components/LandscapePosterProgressBar 2.swift diff --git a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift new file mode 100644 index 00000000..c66ca950 --- /dev/null +++ b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift @@ -0,0 +1,46 @@ +// +// 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 LandscapePosterProgressBar: View { + + private let title: String? + private let progress: Double + + init(title: String? = nil, progress: Double) { + self.title = title + self.progress = progress + } + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.7), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 3) { + + if let title { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + } + + ProgressBar(progress: progress) + .frame(height: 5) + } + .padding(10) + } + } +} diff --git a/jellypig tvOS/Components/ListRowButton.swift b/Swiftfin tvOS/Components/ListRowButton 2.swift similarity index 100% rename from jellypig tvOS/Components/ListRowButton.swift rename to Swiftfin tvOS/Components/ListRowButton 2.swift diff --git a/Swiftfin tvOS/Components/ListRowButton.swift b/Swiftfin tvOS/Components/ListRowButton.swift new file mode 100644 index 00000000..03aebb92 --- /dev/null +++ b/Swiftfin tvOS/Components/ListRowButton.swift @@ -0,0 +1,77 @@ +// +// 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 + +// TODO: on focus, make the cancel and destructive style +// match style like in an `alert` +struct ListRowButton: View { + + // MARK: - Environment + + @Environment(\.isEnabled) + private var isEnabled + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Button Variables + + let title: String + let role: ButtonRole? + let action: () -> Void + + // MARK: - Initializer + + init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { + self.title = title + self.role = role + self.action = action + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(secondaryStyle) + .brightness(isFocused ? 0.25 : 0) + + Text(title) + .foregroundStyle(primaryStyle) + .font(.body.weight(.bold)) + } + } + .buttonStyle(.card) + .frame(maxHeight: 75) + .focused($isFocused) + } + + // MARK: - Primary Style + + private var primaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + // MARK: - Secondary Style + + private var secondaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red.opacity(0.2)) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + } +} diff --git a/jellypig tvOS/Components/ListRowMenu.swift b/Swiftfin tvOS/Components/ListRowMenu 2.swift similarity index 100% rename from jellypig tvOS/Components/ListRowMenu.swift rename to Swiftfin tvOS/Components/ListRowMenu 2.swift diff --git a/Swiftfin tvOS/Components/ListRowMenu.swift b/Swiftfin tvOS/Components/ListRowMenu.swift new file mode 100644 index 00000000..b6800ce1 --- /dev/null +++ b/Swiftfin tvOS/Components/ListRowMenu.swift @@ -0,0 +1,139 @@ +// +// 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 ListRowMenu: View { + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Properties + + private let title: Text + private let subtitle: Subtitle? + private let content: () -> Content + + // MARK: - Body + + var body: some View { + Menu(content: content) { + HStack { + title + .foregroundStyle(isFocused ? .black : .white) + .padding(.leading, 4) + + Spacer() + + if let subtitle { + subtitle + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + + Image(systemName: "chevron.up.chevron.down") + .font(.body.weight(.regular)) + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? Color.white : Color.clear) + ) + .scaleEffect(isFocused ? 1.04 : 1.0) + .animation(.easeInOut(duration: 0.125), value: isFocused) + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + .focused($isFocused) + } +} + +// MARK: - Initializers + +// Base initializer +extension ListRowMenu where Subtitle == Text? { + + init(_ title: Text, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = nil + self.content = content + } + + init(_ title: Text, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content + } + + init(_ title: Text, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = nil + self.content = content + } + + init(_ title: String, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle + self.content = content + } +} + +// Custom view subtitles +extension ListRowMenu { + + init(_ title: String, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle() + self.content = content + } + + init(_ title: Text, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle() + self.content = content + } +} + +// Initialize from a CaseIterable Enum +extension ListRowMenu where Subtitle == Text, Content == AnyView { + + init( + _ title: String, + selection: Binding + ) where ItemType: CaseIterable & Displayable & Hashable, + ItemType.AllCases: RandomAccessCollection + { + self.title = Text(title) + self.subtitle = Text(selection.wrappedValue.displayTitle) + self.content = { + Picker(title, selection: selection) { + ForEach(Array(ItemType.allCases), id: \.self) { option in + Text(option.displayTitle).tag(option) + } + } + .eraseToAnyView() + } + } +} diff --git a/jellypig tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton 2.swift similarity index 100% rename from jellypig tvOS/Components/NonePosterButton.swift rename to Swiftfin tvOS/Components/NonePosterButton 2.swift diff --git a/Swiftfin tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton.swift new file mode 100644 index 00000000..e533275a --- /dev/null +++ b/Swiftfin tvOS/Components/NonePosterButton.swift @@ -0,0 +1,37 @@ +// +// 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 NonePosterButton: View { + + let type: PosterDisplayType + + var body: some View { + Button { + ZStack { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "minus.circle") + .font(.title) + .foregroundColor(.secondary) + + L10n.none.text + .font(.title3) + .foregroundColor(.secondary) + } + } + .posterStyle(type) + } + } + .buttonStyle(.card) + } +} diff --git a/Swiftfin tvOS/Components/OrderedSectionSelectorView 2.swift b/Swiftfin tvOS/Components/OrderedSectionSelectorView 2.swift new file mode 100644 index 00000000..b5b23203 --- /dev/null +++ b/Swiftfin tvOS/Components/OrderedSectionSelectorView 2.swift @@ -0,0 +1,198 @@ +// +// 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 SwiftUI + +struct OrderedSectionSelectorView: View { + + @Environment(\.editMode) + private var editMode + + @State + private var focusedElement: Element? + + @StateObject + private var selection: BindingBox<[Element]> + + private var disabledSelection: [Element] { + sources.filter { !selection.value.contains($0) } + } + + private var label: (Element) -> any View + private let sources: [Element] + private var systemImage: String + + private func move(from source: IndexSet, to destination: Int) { + selection.value.move(fromOffsets: source, toOffset: destination) + editMode?.wrappedValue = .inactive + } + + private func select(element: Element) { + if selection.value.contains(element) { + selection.value.removeAll(where: { $0 == element }) + } else { + selection.value.append(element) + } + } + + var body: some View { + NavigationStack { + SplitFormWindowView() + .descriptionView { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + List { + EnabledSection( + elements: $selection.value, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select, + move: move, + header: { + Group { + HStack { + Text(L10n.enabled) + Spacer() + if editMode?.wrappedValue.isEditing ?? false { + Button(L10n.done) { + withAnimation { + editMode?.wrappedValue = .inactive + } + } + } else { + Button(L10n.edit) { + withAnimation { + editMode?.wrappedValue = .active + } + } + } + } + } + } + ) + + DisabledSection( + elements: disabledSelection, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select + ) + } + .environment(\.editMode, editMode) + } + .animation(.linear(duration: 0.2), value: selection.value) + } + } +} + +private struct EnabledSection: View { + + @Binding + var elements: [Element] + + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + let move: (IndexSet, Int) -> Void + let header: () -> any View + + var body: some View { + Section { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + } header: { + header() + .eraseToAnyView() + } + } +} + +private struct DisabledSection: View { + + let elements: [Element] + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + + var body: some View { + Section(L10n.disabled) { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + } + } +} + +extension OrderedSectionSelectorView { + + init(selection: Binding<[Element]>, sources: [Element]) { + self._selection = StateObject(wrappedValue: BindingBox(source: selection)) + self.sources = sources + self.label = { Text($0.displayTitle).foregroundColor(.primary).eraseToAnyView() } + self.systemImage = "filemenu.and.selection" + } + + func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { + copy(modifying: \.label, with: content) + } + + func systemImage(_ systemName: String) -> Self { + copy(modifying: \.systemImage, with: systemName) + } +} diff --git a/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift b/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift new file mode 100644 index 00000000..b5b23203 --- /dev/null +++ b/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift @@ -0,0 +1,198 @@ +// +// 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 SwiftUI + +struct OrderedSectionSelectorView: View { + + @Environment(\.editMode) + private var editMode + + @State + private var focusedElement: Element? + + @StateObject + private var selection: BindingBox<[Element]> + + private var disabledSelection: [Element] { + sources.filter { !selection.value.contains($0) } + } + + private var label: (Element) -> any View + private let sources: [Element] + private var systemImage: String + + private func move(from source: IndexSet, to destination: Int) { + selection.value.move(fromOffsets: source, toOffset: destination) + editMode?.wrappedValue = .inactive + } + + private func select(element: Element) { + if selection.value.contains(element) { + selection.value.removeAll(where: { $0 == element }) + } else { + selection.value.append(element) + } + } + + var body: some View { + NavigationStack { + SplitFormWindowView() + .descriptionView { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + List { + EnabledSection( + elements: $selection.value, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select, + move: move, + header: { + Group { + HStack { + Text(L10n.enabled) + Spacer() + if editMode?.wrappedValue.isEditing ?? false { + Button(L10n.done) { + withAnimation { + editMode?.wrappedValue = .inactive + } + } + } else { + Button(L10n.edit) { + withAnimation { + editMode?.wrappedValue = .active + } + } + } + } + } + } + ) + + DisabledSection( + elements: disabledSelection, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select + ) + } + .environment(\.editMode, editMode) + } + .animation(.linear(duration: 0.2), value: selection.value) + } + } +} + +private struct EnabledSection: View { + + @Binding + var elements: [Element] + + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + let move: (IndexSet, Int) -> Void + let header: () -> any View + + var body: some View { + Section { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + } header: { + header() + .eraseToAnyView() + } + } +} + +private struct DisabledSection: View { + + let elements: [Element] + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + + var body: some View { + Section(L10n.disabled) { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + } + } +} + +extension OrderedSectionSelectorView { + + init(selection: Binding<[Element]>, sources: [Element]) { + self._selection = StateObject(wrappedValue: BindingBox(source: selection)) + self.sources = sources + self.label = { Text($0.displayTitle).foregroundColor(.primary).eraseToAnyView() } + self.systemImage = "filemenu.and.selection" + } + + func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { + copy(modifying: \.label, with: content) + } + + func systemImage(_ systemName: String) -> Self { + copy(modifying: \.systemImage, with: systemName) + } +} diff --git a/Swiftfin tvOS/Components/PosterButton 2.swift b/Swiftfin tvOS/Components/PosterButton 2.swift new file mode 100644 index 00000000..604222d1 --- /dev/null +++ b/Swiftfin tvOS/Components/PosterButton 2.swift @@ -0,0 +1,222 @@ +// +// 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 JellyfinAPI +import SwiftUI + +private let landscapeMaxWidth: CGFloat = 500 +private let portraitMaxWidth: CGFloat = 500 + +struct PosterButton: View { + + @EnvironmentTypeValue(\.posterOverlayRegistry) + private var posterOverlayRegistry + + @State + private var posterSize: CGSize = .zero + + private var horizontalAlignment: HorizontalAlignment + private let item: Item + private let type: PosterDisplayType + private let label: any View + private let action: () -> Void + + @ViewBuilder + private func poster(overlay: some View) -> some View { + PosterImage(item: item, type: type) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { overlay } + .contentShape(.contextMenuPreview, Rectangle()) + .posterStyle(type) + .posterShadow() + .hoverEffect(.highlight) + } + + var body: some View { + Button(action: action) { + let overlay = posterOverlayRegistry?(item) ?? + PosterButton.DefaultOverlay(item: item) + .eraseToAnyView() + + poster(overlay: overlay) + .trackingSize($posterSize) + + label + .eraseToAnyView() + } + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) + .focusedValue(\.focusedPoster, AnyPoster(item)) + .accessibilityLabel(item.displayTitle) + .matchedContextMenu(for: item) { + EmptyView() + } + } +} + +extension PosterButton { + + init( + item: Item, + type: PosterDisplayType, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> any View + ) { + self.item = item + self.type = type + self.action = action + self.label = label() + self.horizontalAlignment = .leading + } + + func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { + copy(modifying: \.horizontalAlignment, with: alignment) + } +} + +// TODO: Shared default content with iOS? +// - check if content is generally same + +extension PosterButton { + + // MARK: Default Content + + struct TitleContentView: View { + + let item: Item + + var body: some View { + Text(item.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .accessibilityLabel(item.displayTitle) + } + } + + struct SubtitleContentView: View { + + let item: Item + + var body: some View { + Text(item.subtitle ?? "") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + } + + struct TitleSubtitleContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + + SubtitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + } + } + + // TODO: clean up + + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + } + + Subtitle(item: item) + } + } + } + + struct Subtitle: View { + + let item: BaseItemDto + + var body: some View { + + SeparatorHStack { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, 3) + } content: { + SeparatorHStack { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle { + Text(item.displayTitle) + + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + // TODO: Find better way for these indicators, see EpisodeCard + struct DefaultOverlay: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let item: Item + + var body: some View { + ZStack { + if let item = item as? BaseItemDto { + if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { + WatchedIndicator(size: 45) + .isVisible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10) + .isVisible(showProgress) + } else if item.canBePlayed, !item.isLiveStream { + UnwatchedIndicator(size: 45) + .foregroundColor(.jellyfinPurple) + .isVisible(showUnplayed) + } + } + + if item.userData?.isFavorite == true { + FavoriteIndicator(size: 45) + .isVisible(showFavorited) + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift new file mode 100644 index 00000000..604222d1 --- /dev/null +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -0,0 +1,222 @@ +// +// 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 JellyfinAPI +import SwiftUI + +private let landscapeMaxWidth: CGFloat = 500 +private let portraitMaxWidth: CGFloat = 500 + +struct PosterButton: View { + + @EnvironmentTypeValue(\.posterOverlayRegistry) + private var posterOverlayRegistry + + @State + private var posterSize: CGSize = .zero + + private var horizontalAlignment: HorizontalAlignment + private let item: Item + private let type: PosterDisplayType + private let label: any View + private let action: () -> Void + + @ViewBuilder + private func poster(overlay: some View) -> some View { + PosterImage(item: item, type: type) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { overlay } + .contentShape(.contextMenuPreview, Rectangle()) + .posterStyle(type) + .posterShadow() + .hoverEffect(.highlight) + } + + var body: some View { + Button(action: action) { + let overlay = posterOverlayRegistry?(item) ?? + PosterButton.DefaultOverlay(item: item) + .eraseToAnyView() + + poster(overlay: overlay) + .trackingSize($posterSize) + + label + .eraseToAnyView() + } + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) + .focusedValue(\.focusedPoster, AnyPoster(item)) + .accessibilityLabel(item.displayTitle) + .matchedContextMenu(for: item) { + EmptyView() + } + } +} + +extension PosterButton { + + init( + item: Item, + type: PosterDisplayType, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> any View + ) { + self.item = item + self.type = type + self.action = action + self.label = label() + self.horizontalAlignment = .leading + } + + func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { + copy(modifying: \.horizontalAlignment, with: alignment) + } +} + +// TODO: Shared default content with iOS? +// - check if content is generally same + +extension PosterButton { + + // MARK: Default Content + + struct TitleContentView: View { + + let item: Item + + var body: some View { + Text(item.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .accessibilityLabel(item.displayTitle) + } + } + + struct SubtitleContentView: View { + + let item: Item + + var body: some View { + Text(item.subtitle ?? "") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + } + + struct TitleSubtitleContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + + SubtitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + } + } + + // TODO: clean up + + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + } + + Subtitle(item: item) + } + } + } + + struct Subtitle: View { + + let item: BaseItemDto + + var body: some View { + + SeparatorHStack { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, 3) + } content: { + SeparatorHStack { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle { + Text(item.displayTitle) + + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + // TODO: Find better way for these indicators, see EpisodeCard + struct DefaultOverlay: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let item: Item + + var body: some View { + ZStack { + if let item = item as? BaseItemDto { + if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { + WatchedIndicator(size: 45) + .isVisible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10) + .isVisible(showProgress) + } else if item.canBePlayed, !item.isLiveStream { + UnwatchedIndicator(size: 45) + .foregroundColor(.jellyfinPurple) + .isVisible(showUnplayed) + } + } + + if item.userData?.isFavorite == true { + FavoriteIndicator(size: 45) + .isVisible(showFavorited) + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Components/PosterHStack 2.swift b/Swiftfin tvOS/Components/PosterHStack 2.swift new file mode 100644 index 00000000..e6441e20 --- /dev/null +++ b/Swiftfin tvOS/Components/PosterHStack 2.swift @@ -0,0 +1,83 @@ +// +// 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 CollectionHStack +import SwiftUI + +// TODO: trailing content refactor? + +struct PosterHStack: View where Data.Element == Element, Data.Index == Int { + + private var data: Data + private var title: String? + private var type: PosterDisplayType + private var label: (Element) -> any View + private var trailingContent: () -> any View + private var action: (Element) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + if let title { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + Spacer() + } + } + + CollectionHStack( + uniqueElements: data, + columns: type == .landscape ? 4 : 7 + ) { item in + PosterButton( + item: item, + type: type + ) { + action(item) + } label: { + label(item).eraseToAnyView() + } + } + .clipsToBounds(false) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) + .itemSpacing(EdgeInsets.edgePadding - 20) + .scrollBehavior(.continuousLeadingEdge) + } + .focusSection() + } +} + +extension PosterHStack { + + init( + title: String? = nil, + type: PosterDisplayType, + items: Data, + action: @escaping (Element) -> Void, + @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } + ) { + self.init( + data: items, + title: title, + type: type, + label: label, + trailingContent: { EmptyView() }, + action: action + ) + } + + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift new file mode 100644 index 00000000..e6441e20 --- /dev/null +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -0,0 +1,83 @@ +// +// 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 CollectionHStack +import SwiftUI + +// TODO: trailing content refactor? + +struct PosterHStack: View where Data.Element == Element, Data.Index == Int { + + private var data: Data + private var title: String? + private var type: PosterDisplayType + private var label: (Element) -> any View + private var trailingContent: () -> any View + private var action: (Element) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + if let title { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + Spacer() + } + } + + CollectionHStack( + uniqueElements: data, + columns: type == .landscape ? 4 : 7 + ) { item in + PosterButton( + item: item, + type: type + ) { + action(item) + } label: { + label(item).eraseToAnyView() + } + } + .clipsToBounds(false) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) + .itemSpacing(EdgeInsets.edgePadding - 20) + .scrollBehavior(.continuousLeadingEdge) + } + .focusSection() + } +} + +extension PosterHStack { + + init( + title: String? = nil, + type: PosterDisplayType, + items: Data, + action: @escaping (Element) -> Void, + @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } + ) { + self.init( + data: items, + title: title, + type: type, + label: label, + trailingContent: { EmptyView() }, + action: action + ) + } + + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} diff --git a/jellypig tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton 2.swift similarity index 100% rename from jellypig tvOS/Components/SFSymbolButton.swift rename to Swiftfin tvOS/Components/SFSymbolButton 2.swift diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift new file mode 100644 index 00000000..523307a2 --- /dev/null +++ b/Swiftfin tvOS/Components/SFSymbolButton.swift @@ -0,0 +1,70 @@ +// +// 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 +import UIKit + +struct SFSymbolButton: UIViewRepresentable { + + private var onSelect: () -> Void + private let pointSize: CGFloat + private let systemName: String + private let systemNameFocused: String? + + private func makeButtonConfig(_ button: UIButton) { + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + + button.setImage(symbolImage, for: .normal) + + if let systemNameFocused { + let focusedSymbolImage = UIImage(systemName: systemNameFocused, withConfiguration: symbolImageConfig) + + button.setImage(focusedSymbolImage, for: .focused) + } + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { _ in + self.onSelect() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + makeButtonConfig(button) + + return button + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + makeButtonConfig(uiView) + } +} + +extension SFSymbolButton { + + init( + systemName: String, + systemNameFocused: String? = nil, + pointSize: CGFloat = 32 + ) { + self.init( + onSelect: {}, + pointSize: pointSize, + systemName: systemName, + systemNameFocused: systemNameFocused + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Components/SeeAllPosterButton.swift b/Swiftfin tvOS/Components/SeeAllPosterButton 2.swift similarity index 100% rename from jellypig tvOS/Components/SeeAllPosterButton.swift rename to Swiftfin tvOS/Components/SeeAllPosterButton 2.swift diff --git a/Swiftfin tvOS/Components/SeeAllPosterButton.swift b/Swiftfin tvOS/Components/SeeAllPosterButton.swift new file mode 100644 index 00000000..11617446 --- /dev/null +++ b/Swiftfin tvOS/Components/SeeAllPosterButton.swift @@ -0,0 +1,50 @@ +// +// 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 SeeAllPosterButton: View { + + private let type: PosterDisplayType + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) + + L10n.seeAll.text + .font(.title3) + } + } + .posterStyle(type) + } + .buttonStyle(.card) + } +} + +extension SeeAllPosterButton { + + init(type: PosterDisplayType) { + self.init( + type: type, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Components/ServerButton.swift b/Swiftfin tvOS/Components/ServerButton 2.swift similarity index 100% rename from jellypig tvOS/Components/ServerButton.swift rename to Swiftfin tvOS/Components/ServerButton 2.swift diff --git a/Swiftfin tvOS/Components/ServerButton.swift b/Swiftfin tvOS/Components/ServerButton.swift new file mode 100644 index 00000000..4565e022 --- /dev/null +++ b/Swiftfin tvOS/Components/ServerButton.swift @@ -0,0 +1,54 @@ +// +// 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 ServerButton: View { + + let server: SwiftfinStore.State.Server + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) + + Text(server.currentURL.absoluteString) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(10) + } + .buttonStyle(.card) + } +} + +extension ServerButton { + + init(server: SwiftfinStore.State.Server) { + self.server = server + self.onSelect = {} + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Components/SliderContainer 2/SliderContainer.swift b/Swiftfin tvOS/Components/SliderContainer 2/SliderContainer.swift new file mode 100644 index 00000000..0d6c29e3 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer 2/SliderContainer.swift @@ -0,0 +1,202 @@ +// +// 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 SliderContainer: UIViewRepresentable { + + private var value: Binding + private let total: Value + private let onEditingChanged: (Bool) -> Void + private let view: AnyView + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + @ViewBuilder view: @escaping () -> some SliderContentView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = AnyView(view()) + } + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + view: AnyView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = view + } + + func makeUIView(context: Context) -> UISliderContainer { + UISliderContainer( + value: value, + total: total, + onEditingChanged: onEditingChanged, + view: view + ) + } + + func updateUIView(_ uiView: UISliderContainer, context: Context) { + DispatchQueue.main.async { + uiView.containerState.value = value.wrappedValue + } + } +} + +final class UISliderContainer: UIControl { + + private let decelerationMaxVelocity: CGFloat = 1000.0 + private let fineTuningVelocityThreshold: CGFloat = 1000.0 + private let panDampingValue: CGFloat = 50 + + private let onEditingChanged: (Bool) -> Void + private let total: Value + private let valueBinding: Binding + + private var panGestureRecognizer: DirectionalPanGestureRecognizer! + private lazy var progressHostingController: UIHostingController = { + let hostingController = UIHostingController(rootView: AnyView(view.environmentObject(containerState))) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + return hostingController + }() + + private var progressHostingView: UIView { progressHostingController.view } + + let containerState: SliderContainerState + let view: AnyView + private var decelerationTimer: Timer? + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void, + view: AnyView + ) { + self.onEditingChanged = onEditingChanged + self.total = total + self.valueBinding = value + self.containerState = .init( + isEditing: false, + isFocused: false, + value: value.wrappedValue, + total: total + ) + self.view = view + super.init(frame: .zero) + + setupViews() + setupGestureRecognizer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + addSubview(progressHostingView) + NSLayoutConstraint.activate([ + progressHostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressHostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + progressHostingView.topAnchor.constraint(equalTo: topAnchor), + progressHostingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func setupGestureRecognizer() { + panGestureRecognizer = DirectionalPanGestureRecognizer( + direction: .horizontal, + target: self, + action: #selector(didPan) + ) + addGestureRecognizer(panGestureRecognizer) + } + + private var panDeceleratingVelocity: CGFloat = 0 + private var panStartValue: Value = 0 + + @objc + private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { + let translation = gestureRecognizer.translation(in: self).x + let velocity = gestureRecognizer.velocity(in: self).x + + switch gestureRecognizer.state { + case .began: + onEditingChanged(true) + panStartValue = containerState.value + stopDeceleratingTimer() + case .changed: + let dampedTranslation = translation / panDampingValue + let newValue = panStartValue + Value(dampedTranslation) + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + + containerState.value = clampedValue + valueBinding.wrappedValue = clampedValue + case .ended, .cancelled: + panStartValue = containerState.value + + if abs(velocity) > fineTuningVelocityThreshold { + let direction: CGFloat = velocity > 0 ? 1 : -1 + panDeceleratingVelocity = (abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity) / + panDampingValue + decelerationTimer = Timer.scheduledTimer( + timeInterval: 0.01, + target: self, + selector: #selector(handleDeceleratingTimer), + userInfo: nil, + repeats: true + ) + } else { + onEditingChanged(false) + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func handleDeceleratingTimer(time: Timer) { + let newValue = panStartValue + Value(panDeceleratingVelocity) * 0.01 + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + panStartValue = clampedValue + + panDeceleratingVelocity *= 0.92 + + if !isFocused || abs(panDeceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + valueBinding.wrappedValue = clampedValue + containerState.value = clampedValue + onEditingChanged(false) + } + + private func stopDeceleratingTimer() { + decelerationTimer?.invalidate() + decelerationTimer = nil + panDeceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + containerState.isFocused = (context.nextFocusedView == self) + } +} diff --git a/Swiftfin tvOS/Components/SliderContainer 2/SliderContainerState.swift b/Swiftfin tvOS/Components/SliderContainer 2/SliderContainerState.swift new file mode 100644 index 00000000..15f1a606 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer 2/SliderContainerState.swift @@ -0,0 +1,33 @@ +// +// 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 Combine + +class SliderContainerState: ObservableObject { + + @Published + var isEditing: Bool + @Published + var isFocused: Bool + @Published + var value: Value + + let total: Value + + init( + isEditing: Bool, + isFocused: Bool, + value: Value, + total: Value + ) { + self.isEditing = isEditing + self.isFocused = isFocused + self.value = value + self.total = total + } +} diff --git a/Swiftfin tvOS/Components/SliderContainer 2/SliderContentView.swift b/Swiftfin tvOS/Components/SliderContainer 2/SliderContentView.swift new file mode 100644 index 00000000..0833f7d7 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer 2/SliderContentView.swift @@ -0,0 +1,18 @@ +// +// 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 + +protocol SliderContentView: View { + + associatedtype Value: BinaryFloatingPoint + + /// The current state of the slider container. + /// Receive this object as an environment object. + var sliderState: SliderContainerState { get } +} diff --git a/Swiftfin tvOS/Components/SliderContainer/SliderContainer.swift b/Swiftfin tvOS/Components/SliderContainer/SliderContainer.swift new file mode 100644 index 00000000..0d6c29e3 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer/SliderContainer.swift @@ -0,0 +1,202 @@ +// +// 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 SliderContainer: UIViewRepresentable { + + private var value: Binding + private let total: Value + private let onEditingChanged: (Bool) -> Void + private let view: AnyView + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + @ViewBuilder view: @escaping () -> some SliderContentView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = AnyView(view()) + } + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + view: AnyView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = view + } + + func makeUIView(context: Context) -> UISliderContainer { + UISliderContainer( + value: value, + total: total, + onEditingChanged: onEditingChanged, + view: view + ) + } + + func updateUIView(_ uiView: UISliderContainer, context: Context) { + DispatchQueue.main.async { + uiView.containerState.value = value.wrappedValue + } + } +} + +final class UISliderContainer: UIControl { + + private let decelerationMaxVelocity: CGFloat = 1000.0 + private let fineTuningVelocityThreshold: CGFloat = 1000.0 + private let panDampingValue: CGFloat = 50 + + private let onEditingChanged: (Bool) -> Void + private let total: Value + private let valueBinding: Binding + + private var panGestureRecognizer: DirectionalPanGestureRecognizer! + private lazy var progressHostingController: UIHostingController = { + let hostingController = UIHostingController(rootView: AnyView(view.environmentObject(containerState))) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + return hostingController + }() + + private var progressHostingView: UIView { progressHostingController.view } + + let containerState: SliderContainerState + let view: AnyView + private var decelerationTimer: Timer? + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void, + view: AnyView + ) { + self.onEditingChanged = onEditingChanged + self.total = total + self.valueBinding = value + self.containerState = .init( + isEditing: false, + isFocused: false, + value: value.wrappedValue, + total: total + ) + self.view = view + super.init(frame: .zero) + + setupViews() + setupGestureRecognizer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + addSubview(progressHostingView) + NSLayoutConstraint.activate([ + progressHostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressHostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + progressHostingView.topAnchor.constraint(equalTo: topAnchor), + progressHostingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func setupGestureRecognizer() { + panGestureRecognizer = DirectionalPanGestureRecognizer( + direction: .horizontal, + target: self, + action: #selector(didPan) + ) + addGestureRecognizer(panGestureRecognizer) + } + + private var panDeceleratingVelocity: CGFloat = 0 + private var panStartValue: Value = 0 + + @objc + private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { + let translation = gestureRecognizer.translation(in: self).x + let velocity = gestureRecognizer.velocity(in: self).x + + switch gestureRecognizer.state { + case .began: + onEditingChanged(true) + panStartValue = containerState.value + stopDeceleratingTimer() + case .changed: + let dampedTranslation = translation / panDampingValue + let newValue = panStartValue + Value(dampedTranslation) + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + + containerState.value = clampedValue + valueBinding.wrappedValue = clampedValue + case .ended, .cancelled: + panStartValue = containerState.value + + if abs(velocity) > fineTuningVelocityThreshold { + let direction: CGFloat = velocity > 0 ? 1 : -1 + panDeceleratingVelocity = (abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity) / + panDampingValue + decelerationTimer = Timer.scheduledTimer( + timeInterval: 0.01, + target: self, + selector: #selector(handleDeceleratingTimer), + userInfo: nil, + repeats: true + ) + } else { + onEditingChanged(false) + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func handleDeceleratingTimer(time: Timer) { + let newValue = panStartValue + Value(panDeceleratingVelocity) * 0.01 + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + panStartValue = clampedValue + + panDeceleratingVelocity *= 0.92 + + if !isFocused || abs(panDeceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + valueBinding.wrappedValue = clampedValue + containerState.value = clampedValue + onEditingChanged(false) + } + + private func stopDeceleratingTimer() { + decelerationTimer?.invalidate() + decelerationTimer = nil + panDeceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + containerState.isFocused = (context.nextFocusedView == self) + } +} diff --git a/Swiftfin tvOS/Components/SliderContainer/SliderContainerState.swift b/Swiftfin tvOS/Components/SliderContainer/SliderContainerState.swift new file mode 100644 index 00000000..15f1a606 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer/SliderContainerState.swift @@ -0,0 +1,33 @@ +// +// 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 Combine + +class SliderContainerState: ObservableObject { + + @Published + var isEditing: Bool + @Published + var isFocused: Bool + @Published + var value: Value + + let total: Value + + init( + isEditing: Bool, + isFocused: Bool, + value: Value, + total: Value + ) { + self.isEditing = isEditing + self.isFocused = isFocused + self.value = value + self.total = total + } +} diff --git a/Swiftfin tvOS/Components/SliderContainer/SliderContentView.swift b/Swiftfin tvOS/Components/SliderContainer/SliderContentView.swift new file mode 100644 index 00000000..0833f7d7 --- /dev/null +++ b/Swiftfin tvOS/Components/SliderContainer/SliderContentView.swift @@ -0,0 +1,18 @@ +// +// 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 + +protocol SliderContentView: View { + + associatedtype Value: BinaryFloatingPoint + + /// The current state of the slider container. + /// Receive this object as an environment object. + var sliderState: SliderContainerState { get } +} diff --git a/Swiftfin tvOS/Components/SplitFormWindowView 2.swift b/Swiftfin tvOS/Components/SplitFormWindowView 2.swift new file mode 100644 index 00000000..e434030d --- /dev/null +++ b/Swiftfin tvOS/Components/SplitFormWindowView 2.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct SplitFormWindowView: View { + + private var contentView: () -> any View + private var descriptionView: () -> any View + + var body: some View { + HStack { + + descriptionView() + .eraseToAnyView() + .frame(maxWidth: .infinity) + + Form { + contentView() + .eraseToAnyView() + } + .padding(.top) + .scrollClipDisabled() + } + } +} + +extension SplitFormWindowView { + + init() { + self.init( + contentView: { EmptyView() }, + descriptionView: { Color.clear } + ) + } + + func contentView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.contentView, with: content) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/Swiftfin tvOS/Components/SplitFormWindowView.swift b/Swiftfin tvOS/Components/SplitFormWindowView.swift new file mode 100644 index 00000000..e434030d --- /dev/null +++ b/Swiftfin tvOS/Components/SplitFormWindowView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct SplitFormWindowView: View { + + private var contentView: () -> any View + private var descriptionView: () -> any View + + var body: some View { + HStack { + + descriptionView() + .eraseToAnyView() + .frame(maxWidth: .infinity) + + Form { + contentView() + .eraseToAnyView() + } + .padding(.top) + .scrollClipDisabled() + } + } +} + +extension SplitFormWindowView { + + init() { + self.init( + contentView: { EmptyView() }, + descriptionView: { Color.clear } + ) + } + + func contentView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.contentView, with: content) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/Swiftfin tvOS/Components/SplitLoginWindowView 2.swift b/Swiftfin tvOS/Components/SplitLoginWindowView 2.swift new file mode 100644 index 00000000..d4b5ca78 --- /dev/null +++ b/Swiftfin tvOS/Components/SplitLoginWindowView 2.swift @@ -0,0 +1,78 @@ +// +// 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 Foundation +import SwiftUI + +struct SplitLoginWindowView: View { + + // MARK: - Loading State + + private let isLoading: Bool + + // MARK: - Content Variables + + private let leadingContentView: Leading + private let trailingContentView: Trailing + + // MARK: - Background Variable + + private let backgroundImageSource: ImageSource? + + // MARK: - Body + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 22) { + leadingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + + Divider() + .padding(.vertical, 100) + + VStack(alignment: .leading, spacing: 22) { + trailingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + } + .navigationBarBranding(isLoading: isLoading) + .background { + if let backgroundImageSource { + ZStack { + ImageView(backgroundImageSource) + .aspectRatio(contentMode: .fill) + .id(backgroundImageSource) + .transition(.opacity) + .animation(.linear, value: backgroundImageSource) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } +} + +extension SplitLoginWindowView { + + init( + isLoading: Bool = false, + backgroundImageSource: ImageSource? = nil, + @ViewBuilder leadingContentView: @escaping () -> Leading, + @ViewBuilder trailingContentView: @escaping () -> Trailing + ) { + self.backgroundImageSource = backgroundImageSource + self.isLoading = isLoading + self.leadingContentView = leadingContentView() + self.trailingContentView = trailingContentView() + } +} diff --git a/Swiftfin tvOS/Components/SplitLoginWindowView.swift b/Swiftfin tvOS/Components/SplitLoginWindowView.swift new file mode 100644 index 00000000..d4b5ca78 --- /dev/null +++ b/Swiftfin tvOS/Components/SplitLoginWindowView.swift @@ -0,0 +1,78 @@ +// +// 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 Foundation +import SwiftUI + +struct SplitLoginWindowView: View { + + // MARK: - Loading State + + private let isLoading: Bool + + // MARK: - Content Variables + + private let leadingContentView: Leading + private let trailingContentView: Trailing + + // MARK: - Background Variable + + private let backgroundImageSource: ImageSource? + + // MARK: - Body + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 22) { + leadingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + + Divider() + .padding(.vertical, 100) + + VStack(alignment: .leading, spacing: 22) { + trailingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + } + .navigationBarBranding(isLoading: isLoading) + .background { + if let backgroundImageSource { + ZStack { + ImageView(backgroundImageSource) + .aspectRatio(contentMode: .fill) + .id(backgroundImageSource) + .transition(.opacity) + .animation(.linear, value: backgroundImageSource) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } +} + +extension SplitLoginWindowView { + + init( + isLoading: Bool = false, + backgroundImageSource: ImageSource? = nil, + @ViewBuilder leadingContentView: @escaping () -> Leading, + @ViewBuilder trailingContentView: @escaping () -> Trailing + ) { + self.backgroundImageSource = backgroundImageSource + self.isLoading = isLoading + self.leadingContentView = leadingContentView() + self.trailingContentView = trailingContentView() + } +} diff --git a/jellypig tvOS/Components/StepperView.swift b/Swiftfin tvOS/Components/StepperView 2.swift similarity index 100% rename from jellypig tvOS/Components/StepperView.swift rename to Swiftfin tvOS/Components/StepperView 2.swift diff --git a/Swiftfin tvOS/Components/StepperView.swift b/Swiftfin tvOS/Components/StepperView.swift new file mode 100644 index 00000000..3c7492d2 --- /dev/null +++ b/Swiftfin tvOS/Components/StepperView.swift @@ -0,0 +1,114 @@ +// +// 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 StepperView: View { + + @Binding + private var value: Value + + @State + private var updatedValue: Value + @Environment(\.presentationMode) + private var presentationMode + + private var title: String + private var description: String? + private var range: ClosedRange + private let step: Value.Stride + private var formatter: (Value) -> String + private var onCloseSelected: () -> Void + + var body: some View { + VStack { + VStack { + Spacer() + + Text(title) + .font(.title) + .fontWeight(.semibold) + + if let description { + Text(description) + .padding(.vertical) + } + } + .frame(maxHeight: .infinity) + + formatter(updatedValue).text + .font(.title) + .frame(height: 250) + + VStack { + + HStack { + Button { + if updatedValue > range.lowerBound { + updatedValue = max(updatedValue.advanced(by: -step), range.lowerBound) + value = updatedValue + } + } label: { + Image(systemName: "minus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + + Button { + if updatedValue < range.upperBound { + updatedValue = min(updatedValue.advanced(by: step), range.upperBound) + value = updatedValue + } + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + } + + Button(L10n.close) { + onCloseSelected() + presentationMode.wrappedValue.dismiss() + } + + Spacer() + } + .frame(maxHeight: .infinity) + } + } +} + +extension StepperView { + + init( + title: String, + description: String? = nil, + value: Binding, + range: ClosedRange, + step: Value.Stride + ) { + self._value = value + self._updatedValue = State(initialValue: value.wrappedValue) + self.title = title + self.description = description + self.range = range + self.step = step + self.formatter = { $0.description } + self.onCloseSelected = {} + } + + func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { + copy(modifying: \.formatter, with: formatter) + } + + func onCloseSelected(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onCloseSelected, with: action) + } +} diff --git a/jellypig tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift b/Swiftfin tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift similarity index 100% rename from jellypig tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift rename to Swiftfin tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift diff --git a/Swiftfin tvOS/Extensions/View/View-tvOS.swift b/Swiftfin tvOS/Extensions/View/View-tvOS.swift new file mode 100644 index 00000000..3b886b33 --- /dev/null +++ b/Swiftfin tvOS/Extensions/View/View-tvOS.swift @@ -0,0 +1,47 @@ +// +// 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 +import SwiftUIIntrospect + +extension View { + + @ViewBuilder + func navigationBarBranding( + isLoading: Bool = false + ) -> some View { + modifier( + NavigationBarBrandingModifier( + isLoading: isLoading + ) + ) + } + + /// - Important: This does nothing on tvOS. + @ViewBuilder + func statusBarHidden() -> some View { + self + } + + /// - Important: This does nothing on tvOS. + @ViewBuilder + func prefersStatusBarHidden(_ hidden: Bool) -> some View { + self + } +} + +extension EnvironmentValues { + + @Entry + var presentationCoordinator: PresentationCoordinator = .init() +} + +struct PresentationCoordinator { + var isPresented: Bool = false +} diff --git a/Swiftfin tvOS/Objects/FocusGuide.swift b/Swiftfin tvOS/Objects/FocusGuide.swift new file mode 100644 index 00000000..2b449cd5 --- /dev/null +++ b/Swiftfin tvOS/Objects/FocusGuide.swift @@ -0,0 +1,153 @@ +// +// 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 FocusGuideModifier: ViewModifier { + + @FocusState + var focusDirection: FocusDirection? + @EnvironmentObject + var focusGuide: FocusGuide + + let focusConstructor: FocusConstructor + let onContentFocus: (() -> Void)? + + let debug = false + + func body(content: Content) -> some View { + VStack(spacing: 0) { + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.topTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .top) + + HStack(spacing: 0) { + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.leftTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .left) + + content + .focused($focusDirection, equals: .content) + + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.rightTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .right) + } + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.bottomTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .bottom) + } + .onChange(of: focusDirection) { _, focusDirection in + guard let focusDirection = focusDirection else { return } + switch focusDirection { + case .top: + focusGuide.transition(to: focusConstructor.topTarget!) + case .bottom: + focusGuide.transition(to: focusConstructor.bottomTarget!) + case .left: + focusGuide.transition(to: focusConstructor.leftTarget!) + case .right: + focusGuide.transition(to: focusConstructor.rightTarget!) + case .content: () + } + } + .onChange(of: focusGuide.focusedTag) { _, newTag in + if newTag == focusConstructor.tag { + if let onContentFocus { + onContentFocus() + } else { + focusDirection = .content + } + } + } + } +} + +extension View { + func focusGuide( + _ focusGuide: FocusGuide, + tag: String, + onContentFocus: (() -> Void)? = nil, + top: String? = nil, + bottom: String? = nil, + left: String? = nil, + right: String? = nil + ) -> some View { + let focusConstructor = FocusConstructor( + tag: tag, + topTarget: top, + bottomTarget: bottom, + leftTarget: left, + rightTarget: right + ) + return modifier(FocusGuideModifier(focusConstructor: focusConstructor, onContentFocus: onContentFocus)) + .environmentObject(focusGuide) + } +} + +enum FocusDirection: String { + case top + case bottom + case content + case left + case right +} + +struct FocusConstructor { + + let tag: String + let topTarget: String? + let bottomTarget: String? + let leftTarget: String? + let rightTarget: String? + + init( + tag: String, + topTarget: String?, + bottomTarget: String?, + leftTarget: String?, + rightTarget: String? + ) { + self.tag = tag + self.topTarget = topTarget + self.bottomTarget = bottomTarget + self.leftTarget = leftTarget + self.rightTarget = rightTarget + } +} + +// TODO: generic focus values instead of strings +// TODO: keep mapping of all tag connections, +// only add complete connections + +class FocusGuide: ObservableObject { + + @Published + private(set) var focusedTag: String? + + private(set) var lastFocusedTag: String? + + func transition(to tag: String?) { + lastFocusedTag = focusedTag + focusedTag = tag + } +} diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png new file mode 100644 index 00000000..5e44064e Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png new file mode 100644 index 00000000..c815cf40 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png new file mode 100644 index 00000000..4036a934 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png new file mode 100644 index 00000000..55b52d8a Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png new file mode 100644 index 00000000..00503453 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png new file mode 100644 index 00000000..41d21662 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png new file mode 100644 index 00000000..57e79b8d Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png new file mode 100644 index 00000000..57e79b8d Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png new file mode 100644 index 00000000..1926d6c1 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png new file mode 100644 index 00000000..1926d6c1 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png new file mode 100644 index 00000000..9ebc46e0 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png new file mode 100644 index 00000000..9ebc46e0 Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png new file mode 100644 index 00000000..462287af Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png differ diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png new file mode 100644 index 00000000..462287af Binary files /dev/null and b/Swiftfin tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json diff --git a/Swiftfin tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/Swiftfin tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg new file mode 100644 index 00000000..db72d151 --- /dev/null +++ b/Swiftfin tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg @@ -0,0 +1,15 @@ + + + Combined Shape + + + + + + + + + + + + \ No newline at end of file diff --git a/jellypig tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg b/Swiftfin tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg rename to Swiftfin tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json b/Swiftfin tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json rename to Swiftfin tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg b/Swiftfin tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg rename to Swiftfin tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg diff --git a/jellypig tvOS/Resources/Info.plist b/Swiftfin tvOS/Resources/Info.plist similarity index 100% rename from jellypig tvOS/Resources/Info.plist rename to Swiftfin tvOS/Resources/Info.plist diff --git a/jellypig tvOS/Views/AppLoadingView.swift b/Swiftfin tvOS/Views/AppLoadingView.swift similarity index 100% rename from jellypig tvOS/Views/AppLoadingView.swift rename to Swiftfin tvOS/Views/AppLoadingView.swift diff --git a/Swiftfin tvOS/Views/AppSettingsView/AppSettingsView.swift b/Swiftfin tvOS/Views/AppSettingsView/AppSettingsView.swift new file mode 100644 index 00000000..8eee997d --- /dev/null +++ b/Swiftfin tvOS/Views/AppSettingsView/AppSettingsView.swift @@ -0,0 +1,96 @@ +// +// 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 + +struct AppSettingsView: View { + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + + @Default(.appAppearance) + private var appearance + + @Router + private var router + + @StateObject + private var viewModel = SettingsViewModel() + + @State + private var resetUserSettingsSelected: Bool = false + @State + private var removeAllServersSelected: Bool = false + + private var selectedServer: ServerState? { + viewModel.servers.first { server in + selectUserAllServersSplashscreen == .server(id: server.id) + } + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + LabeledContent( + L10n.version, + value: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" + ) + + Section { + + Toggle(L10n.useSplashscreen, isOn: $selectUserUseSplashscreen) + + if selectUserUseSplashscreen { + ListRowMenu(L10n.servers) { + if selectUserAllServersSplashscreen == .all { + Label(L10n.random, systemImage: "dice.fill") + } else if let selectedServer { + Text(selectedServer.name) + } else { + Text(L10n.none) + } + } content: { + Picker(L10n.servers, selection: $selectUserAllServersSplashscreen) { + Label(L10n.random, systemImage: "dice.fill") + .tag(SelectUserServerSelection.all) + + ForEach(viewModel.servers) { server in + Text(server.name) + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } + } + } header: { + Text(L10n.splashscreen) + } footer: { + if selectUserUseSplashscreen { + Text(L10n.splashscreenFooter) + } + } + + SignOutIntervalSection() + + ChevronButton(L10n.logs) { + router.route(to: .log) + } + } + .navigationTitle(L10n.advanced) + } +} diff --git a/jellypig tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift b/Swiftfin tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift similarity index 100% rename from jellypig tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift rename to Swiftfin tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift diff --git a/Swiftfin tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift b/Swiftfin tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift new file mode 100644 index 00000000..d420f972 --- /dev/null +++ b/Swiftfin tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift @@ -0,0 +1,54 @@ +// +// 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 AppSettingsView { + + struct SignOutIntervalSection: View { + + @Router + private var router + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + @Default(.signOutOnBackground) + private var signOutOnBackground + @Default(.signOutOnClose) + private var signOutOnClose + + @State + private var isEditingBackgroundSignOutInterval: Bool = false + + var body: some View { + Section { + Toggle(L10n.signoutClose, isOn: $signOutOnClose) + } footer: { + Text(L10n.signoutCloseFooter) + } + + Section { + Toggle(L10n.signoutBackground, isOn: $signOutOnBackground) + + if signOutOnBackground { + ChevronButton( + L10n.duration, + subtitle: Text(backgroundSignOutInterval, format: .hourMinute) + ) { + router.route(to: .hourPicker) + } + } + } footer: { + Text( + L10n.signoutBackgroundFooter + ) + } + } + } +} diff --git a/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift similarity index 83% rename from jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift rename to Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift index 09037cf3..1ea1cb2d 100644 --- a/jellypig tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -13,8 +13,8 @@ import SwiftUI struct ChannelLibraryView: View { - @EnvironmentObject - private var router: VideoPlayerWrapperCoordinator.Router + @Router + private var router @StateObject private var viewModel = ChannelLibraryViewModel() @@ -28,10 +28,10 @@ struct ChannelLibraryView: View { WideChannelGridItem(channel: channel) .onSelect { guard let mediaSource = channel.channel.mediaSources?.first else { return } - router.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) - ) +// router.route( +// to: \.liveVideoPlayer, +// LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) +// ) } } .onReachedBottomEdge(offset: .offset(300)) { @@ -41,9 +41,6 @@ struct ChannelLibraryView: View { var body: some View { ZStack { - Color(red: 0.15, green: 0.05, blue: 0.1) - .ignoresSafeArea() - switch viewModel.state { case .content: if viewModel.elements.isEmpty { diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift new file mode 100644 index 00000000..6b2a45c6 --- /dev/null +++ b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift @@ -0,0 +1,153 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ChannelLibraryView { + + struct WideChannelGridItem: View { + + @Default(.accentColor) + private var accentColor + + @State + private var now: Date = .now + + let channel: ChannelProgram + + private var onSelect: () -> Void + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + @ViewBuilder + private var channelLogo: some View { + VStack { + ZStack { + Color.clear + + ImageView(channel.portraitImageSources(maxWidth: 110, quality: 90)) + .image { + $0.aspectRatio(contentMode: .fit) + } + .failure { + SystemImageContentView(systemName: channel.systemImage, ratio: 0.66) + } + .placeholder { _ in + EmptyView() + } + } + .aspectRatio(1.0, contentMode: .fit) + + Text(channel.channel.number ?? "") + .font(.body) + .lineLimit(1) + .foregroundStyle(.primary) + } + } + + @ViewBuilder + private func programLabel(for program: BaseItemDto) -> some View { + HStack(alignment: .top, spacing: EdgeInsets.edgePadding / 2) { + AlternateLayoutView(alignment: .leading) { + Text("00:00 AM") + .monospacedDigit() + } content: { + if let startDate = program.startDate { + Text(startDate, style: .time) + .monospacedDigit() + } else { + Text(String.emptyDash) + } + } + + Text(program.displayTitle) + } + .lineLimit(1) + } + + @ViewBuilder + private var programListView: some View { + VStack(alignment: .leading, spacing: 0) { + if let currentProgram = channel.currentProgram { + ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) + .frame(height: 8) + .padding(.bottom, 8) + .foregroundStyle(accentColor) + + programLabel(for: currentProgram) + .font(.caption.weight(.bold)) + } + + if let nextProgram = channel.programAfterCurrent(offset: 0) { + programLabel(for: nextProgram) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let futureProgram = channel.programAfterCurrent(offset: 1) { + programLabel(for: futureProgram) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .id(channel.currentProgram) + } + + var body: some View { + Button { + onSelect() + } label: { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding / 2) { + + channelLogo + .frame(width: 110) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(channel.displayTitle) + .font(.body) + .fontWeight(.bold) + .lineLimit(1) + .foregroundStyle(.primary) + + if channel.programs.isNotEmpty { + programListView + } + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, EdgeInsets.edgePadding / 2) + } + .buttonStyle(.card) + .frame(height: 200) + .onReceive(timer) { newValue in + now = newValue + } + .animation(.linear(duration: 0.2), value: channel.currentProgram) + } + } +} + +extension ChannelLibraryView.WideChannelGridItem { + + init(channel: ChannelProgram) { + self.init( + channel: channel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Views/FontPickerView.swift b/Swiftfin tvOS/Views/FontPickerView.swift similarity index 100% rename from jellypig tvOS/Views/FontPickerView.swift rename to Swiftfin tvOS/Views/FontPickerView.swift diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift new file mode 100644 index 00000000..ecf5a96b --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift @@ -0,0 +1,59 @@ +// +// 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 HomeView { + + struct CinematicRecentlyAddedView: View { + + @Router + private var router + + @ObservedObject + var viewModel: RecentlyAddedLibraryViewModel + + private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: UIScreen.main.bounds.width * 0.4, + maxHeight: 200 + ) + } else { + return item.imageSource( + .logo, + maxWidth: UIScreen.main.bounds.width * 0.4, + maxHeight: 200 + ) + } + } + + var body: some View { + CinematicItemSelector(items: viewModel.elements.elements) + .topContent { item in + ImageView(itemSelectorImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + .onSelect { item in + router.route(to: .item(item: item)) + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift new file mode 100644 index 00000000..65ee2a66 --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift @@ -0,0 +1,73 @@ +// +// 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 HomeView { + + struct CinematicResumeView: View { + + @Router + private var router + + @ObservedObject + var viewModel: HomeViewModel + + private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: UIScreen.main.bounds.width * 0.4, + maxHeight: 200 + ) + } else { + return item.imageSource( + .logo, + maxWidth: UIScreen.main.bounds.width * 0.4, + maxHeight: 200 + ) + } + } + + var body: some View { + CinematicItemSelector(items: viewModel.resumeItems.elements) + .topContent { item in + ImageView(itemSelectorImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + .content { item in + // TODO: clean up + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent.Subtitle(item: item) + } else { + Text(" ") + } + } + .onSelect { item in + router.route(to: .item(item: item)) + } + .posterOverlay(for: BaseItemDto.self) { item in + LandscapePosterProgressBar( + title: item.progressLabel ?? L10n.continue, + progress: (item.userData?.playedPercentage ?? 0) / 100 + ) + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift new file mode 100644 index 00000000..c971b778 --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift @@ -0,0 +1,38 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension HomeView { + + struct LatestInLibraryView: View { + + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + + @Router + private var router + + @ObservedObject + var viewModel: LatestInLibraryViewModel + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + type: latestInLibraryPosterType, + items: viewModel.elements + ) { item in + router.route(to: .item(item: item)) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift new file mode 100644 index 00000000..f650b607 --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift @@ -0,0 +1,37 @@ +// +// 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 HomeView { + + struct NextUpView: View { + + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + + @Router + private var router + + @ObservedObject + var viewModel: NextUpLibraryViewModel + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.nextUp, + type: nextUpPosterType, + items: viewModel.elements + ) { item in + router.route(to: .item(item: item)) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift new file mode 100644 index 00000000..8ec2388b --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift @@ -0,0 +1,37 @@ +// +// 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 HomeView { + + struct RecentlyAddedView: View { + + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + + @Router + private var router + + @ObservedObject + var viewModel: RecentlyAddedLibraryViewModel + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.recentlyAdded, + type: recentlyAddedPosterType, + items: viewModel.elements + ) { item in + router.route(to: .item(item: item)) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift new file mode 100644 index 00000000..41b54641 --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -0,0 +1,83 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +struct HomeView: View { + + @Router + private var router + + @StateObject + private var viewModel = HomeViewModel() + + @Default(.Customization.Home.showRecentlyAdded) + private var showRecentlyAdded + + @ViewBuilder + private var contentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + if viewModel.resumeItems.isNotEmpty { + CinematicResumeView(viewModel: viewModel) + + NextUpView(viewModel: viewModel.nextUpViewModel) + + if showRecentlyAdded { + RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + } + } else { + if showRecentlyAdded { + CinematicRecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + } + + NextUpView(viewModel: viewModel.nextUpViewModel) + .safeAreaPadding(.top, 150) + } + + ForEach(viewModel.libraries) { viewModel in + LatestInLibraryView(viewModel: viewModel) + } + } + } + } + + var body: some View { + ZStack { + // This keeps the ErrorView vertically aligned with the PagingLibraryView + Color.clear + + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .onFirstAppear { + viewModel.send(.refresh) + } + .ignoresSafeArea() + .sinceLastDisappear { interval in + if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { + viewModel.send(.backgroundRefresh) + viewModel.notificationsReceived.remove(.itemMetadataDidChange) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemOverviewView.swift b/Swiftfin tvOS/Views/ItemOverviewView.swift similarity index 100% rename from jellypig tvOS/Views/ItemOverviewView.swift rename to Swiftfin tvOS/Views/ItemOverviewView.swift diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift new file mode 100644 index 00000000..a3e951a3 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift @@ -0,0 +1,97 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import OrderedCollections +import SwiftUI + +extension ItemView { + + struct CollectionItemContentView: View { + + typealias Element = OrderedDictionary.Elements.Element + + @Router + private var router + + @ObservedObject + var viewModel: CollectionItemViewModel + + // MARK: - Episode Poster HStack + + private func episodeHStack(element: Element) -> some View { + VStack(alignment: .leading, spacing: 20) { + + HStack { + Text(L10n.episodes) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + Spacer() + } + + CollectionHStack( + uniqueElements: element.value.elements, + id: \.unwrappedIDHashOrZero, + columns: 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + .padding(.horizontal, 4) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + } + .focusSection() + } + + // MARK: - Default Poster HStack + + private func posterHStack(element: Element) -> some View { + PosterHStack( + title: element.key.pluralDisplayTitle, + type: .portrait, + items: element.value.elements + ) { item in + router.route(to: .item(item: item)) + } + .focusSection() + + // TODO: Is this possible? + /* .trailing { + SeeMoreButton() { + router.route(to: .library(viewModel: element.value)) + } + } */ + } + + var body: some View { + VStack(spacing: 0) { + ForEach( + viewModel.sections.elements, + id: \.key + ) { element in + if element.key == .episode { + episodeHStack(element: element) + } else { + posterHStack(element: element) + } + } + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/AboutView.swift rename to Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift rename to Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift new file mode 100644 index 00000000..3fa3672f --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift @@ -0,0 +1,55 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView.AboutView { + + struct ImageCard: View { + + // MARK: - Environment & Observed Objects + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + // MARK: - Body + + var body: some View { + PosterButton( + item: viewModel.item, + type: .portrait, + action: onSelect + ) { + EmptyView() + } + .posterOverlay(for: BaseItemDto.self) { _ in EmptyView() } + .frame(height: 405) + } + + // MARK: - On Select + + // Switch case to allow other funcitonality if we need to expand this beyond episode > series + private func onSelect() { + switch viewModel.item.type { + case .episode: + if let episodeViewModel = viewModel as? EpisodeItemViewModel, + let seriesItem = episodeViewModel.seriesItem + { + router.route(to: .item(item: seriesItem)) + } + default: + break + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift new file mode 100644 index 00000000..1de2bf99 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift @@ -0,0 +1,42 @@ +// +// 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.AboutView { + + struct MediaSourcesCard: View { + + @Router + private var router + + let subtitle: String? + let source: MediaSourceInfo + + var body: some View { + Card(title: L10n.media, subtitle: subtitle) + .content { + if let mediaStreams = source.mediaStreams { + VStack(alignment: .leading) { + Text(mediaStreams.compactMap(\.displayTitle).prefix(4).joined(separator: "\n")) + .font(.footnote) + + if mediaStreams.count > 4 { + L10n.seeMore.text + .font(.footnote) + } + } + } + } + .onSelect { + router.route(to: .mediaSourceInfo(source: source)) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift new file mode 100644 index 00000000..89a8d6a6 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift @@ -0,0 +1,33 @@ +// +// 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.AboutView { + + struct OverviewCard: View { + + @Router + private var router + + let item: BaseItemDto + + var body: some View { + Card(title: item.displayTitle) + .content { + TruncatedText(item.overview ?? L10n.noOverviewAvailable) + .font(.subheadline) + .lineLimit(4) + } + .onSelect { + router.route(to: .itemOverview(item: item)) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift rename to Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift new file mode 100644 index 00000000..40da84e2 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -0,0 +1,195 @@ +// +// 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 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 + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + @StateObject + private var deleteViewModel: DeleteItemViewModel + + // MARK: - Dialog States + + @State + private var showConfirmationDialog = false + @State + private var isPresentingEventAlert = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Can Delete Item + + private var canDelete: Bool { + viewModel.userSession.user.permissions.items.canDelete(item: viewModel.item) + } + + // MARK: - Can Refresh Item + + private var canRefresh: Bool { + viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) + } + + // MARK: - Can Manage Subtitles + + private var canManageSubtitles: Bool { + viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) + } + + // MARK: - Deletion or Refreshing is Enabled + + private var enableMenu: Bool { + canDelete || canRefresh + } + + // 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) { + self.viewModel = viewModel + self._deleteViewModel = StateObject(wrappedValue: .init(item: viewModel.item)) + } + + // MARK: - Body + + var body: some View { + HStack(alignment: .center, spacing: 20) { + + // MARK: Toggle Played + + if viewModel.item.canBePlayed { + let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true + + ActionButton( + L10n.played, + icon: "checkmark.circle", + selectedIcon: "checkmark.circle.fill" + ) { + viewModel.send(.toggleIsPlayed) + } + .foregroundStyle(Color.jellyfinPurple) + .isSelected(isCheckmarkSelected) + .frame(minWidth: 100, maxWidth: .infinity) + } + + // MARK: Toggle Favorite + + let isHeartSelected = viewModel.item.userData?.isFavorite == true + + ActionButton( + L10n.favorited, + icon: "heart.circle", + selectedIcon: "heart.circle.fill" + ) { + viewModel.send(.toggleIsFavorite) + } + .foregroundStyle(.pink) + .isSelected(isHeartSelected) + .frame(minWidth: 100, maxWidth: .infinity) + + // MARK: Watch a Trailer + + if hasTrailers { + TrailerMenu( + localTrailers: viewModel.localTrailers, + externalTrailers: viewModel.item.remoteTrailers ?? [] + ) + } + + // MARK: Advanced Options + + if enableMenu { + ActionButton(L10n.advanced, icon: "ellipsis", isCompact: true) { + if canRefresh || canManageSubtitles { + Section(L10n.manage) { + if canRefresh { + RefreshMetadataButton(item: viewModel.item) + } + + if canManageSubtitles { + Button(L10n.subtitles, systemImage: "textformat") { + router.route( + to: .searchSubtitle( + viewModel: .init(item: viewModel.item) + ) + ) + } + } + } + } + + if canDelete { + Section { + Button(L10n.delete, systemImage: "trash", role: .destructive) { + showConfirmationDialog = true + } + } + } + } + .frame(width: 60) + } + } + .frame(height: 100) + .padding(.top, 1) + .padding(.bottom, 10) + .confirmationDialog( + L10n.deleteItemConfirmationMessage, + isPresented: $showConfirmationDialog, + titleVisibility: .visible + ) { + Button(L10n.confirm, role: .destructive) { + deleteViewModel.send(.delete) + } + Button(L10n.cancel, role: .cancel) {} + } + .onReceive(deleteViewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .deleted: + router.dismiss() + } + } + .errorMessage($error) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/ActionButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/ActionButton.swift new file mode 100644 index 00000000..20d1b032 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/ActionButton.swift @@ -0,0 +1,126 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct ActionButton: View { + + // MARK: - Environment Objects + + @Environment(\.isSelected) + private var isSelected + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + private let content: () -> Content + private let icon: String + private let isCompact: Bool + private let selectedIcon: String? + private let title: String + private let onSelect: () -> Void + + private var labelIconName: String { + isSelected ? selectedIcon ?? icon : icon + } + + // MARK: - Body + + var body: some View { + Group { + if Content.self == EmptyView.self { + Button(action: onSelect) { + labelView + } + .buttonStyle(.card) + } else { + Menu(content: content) { + labelView + } + .scaleEffect(isFocused ? 1.2 : 1.0) + .animation( + .spring(response: 0.2, dampingFraction: 1), value: isFocused + ) + .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .focused($isFocused) + } + } + .focused($isFocused) + } + + // MARK: - Label Views + + private var labelView: some View { + ZStack { + let isButton = Content.self == EmptyView.self + + if isButton, isSelected { + RoundedRectangle(cornerRadius: 10) + .fill( + isFocused ? AnyShapeStyle(HierarchicalShapeStyle.primary) : + AnyShapeStyle(HierarchicalShapeStyle.primary.opacity(0.5)) + ) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? .white : .white.opacity(0.5)) + } + + Label(title, systemImage: labelIconName) + .focusEffectDisabled() + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.black) + .labelStyle(.iconOnly) + .rotationEffect(isCompact ? .degrees(90) : .degrees(0)) + } + .accessibilityLabel(title) + } + } +} + +// MARK: - Initializers + +extension ItemView.ActionButton { + + // MARK: Button Initializer + + init( + _ title: String, + icon: String, + selectedIcon: String, + onSelect: @escaping () -> Void + ) where Content == EmptyView { + self.title = title + self.icon = icon + self.isCompact = false + self.selectedIcon = selectedIcon + self.onSelect = onSelect + self.content = { EmptyView() } + } + + // MARK: Menu Initializer + + init( + _ title: String, + icon: String, + isCompact: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.icon = icon + self.isCompact = isCompact + self.selectedIcon = nil + self.onSelect = {} + self.content = content + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift new file mode 100644 index 00000000..8653d8e2 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift @@ -0,0 +1,85 @@ +// +// 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 RefreshMetadataButton: View { + + // MARK: - State Object + + @StateObject + private var viewModel: RefreshMetadataViewModel + + // MARK: - Initializer + + init(item: BaseItemDto) { + _viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item)) + } + + // MARK: - Body + + var body: some View { + Menu { + Section(L10n.metadata) { + Button(L10n.findMissing, systemImage: "magnifyingglass") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: false + ) + } + + Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .none, + replaceMetadata: true, + replaceImages: false + ) + } + + Button(L10n.replaceImages, systemImage: "photo") { + viewModel.refreshMetadata( + metadataRefreshMode: .none, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: true + ) + } + + Button(L10n.replaceAll, systemImage: "staroflife") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: true, + replaceImages: true + ) + } + } + } label: { + HStack { + Text(L10n.refreshMetadata) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + .fontWeight(.semibold) + } + } + .foregroundStyle(.primary, .secondary) + .disabled(viewModel.state == .refreshing) + .errorMessage($viewModel.error) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift new file mode 100644 index 00000000..15d813b2 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift @@ -0,0 +1,152 @@ +// +// 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 Logging +import SwiftUI + +extension ItemView { + + struct TrailerMenu: View { + + private let logger = Logger.swiftfin() + + // MARK: - Stored Value + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Observed & Envirnoment Objects + + @Router + private var 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(manager: OnlineVideoPlayerManager( +// item: trailer, +// mediaSource: selectedMediaSource +// )) +// ) + } else { + logger.log(level: .error, "No media sources found") + error = JellyfinAPIError(L10n.unknownError) + } + } + + // MARK: - Play: External Trailer + + private func playExternalTrailer(_ trailer: MediaURL) { + if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) { success in + guard !success else { return } + + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } else { + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift new file mode 100644 index 00000000..85f65419 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift @@ -0,0 +1,154 @@ +// +// 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 AttributesHStack: View { + + @ObservedObject + private var viewModel: ItemViewModel + + private let alignment: HorizontalAlignment + private let attributes: [ItemViewAttribute] + private let flowDirection: FlowLayout.Direction + + init( + attributes: [ItemViewAttribute], + viewModel: ItemViewModel, + alignment: HorizontalAlignment = .center, + flowDirection: FlowLayout.Direction = .up + ) { + self.viewModel = viewModel + self.alignment = alignment + self.attributes = attributes + self.flowDirection = flowDirection + } + + var body: some View { + if attributes.isNotEmpty { + FlowLayout( + alignment: alignment, + direction: flowDirection, + spacing: 20 + ) { + ForEach(attributes, id: \.self) { attribute in + switch attribute { + case .ratingCritics: CriticRating() + case .ratingCommunity: CommunityRating() + case .ratingOfficial: OfficialRating() + case .videoQuality: VideoQuality() + case .audioChannels: AudioChannels() + case .subtitles: Subtitles() + } + } + } + .foregroundStyle(Color(UIColor.darkGray)) + .lineLimit(1) + } + } + + @ViewBuilder + private func CriticRating() -> some View { + if let criticRating = viewModel.item.criticRating { + AttributeBadge( + style: .outline, + title: Text("\(criticRating, specifier: "%.0f")") + ) { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) + } + } + } + } + + @ViewBuilder + private func CommunityRating() -> some View { + if let communityRating = viewModel.item.communityRating { + AttributeBadge( + style: .outline, + title: Text("\(communityRating, specifier: "%.01f")"), + systemName: "star.fill" + ) + } + } + + @ViewBuilder + private func OfficialRating() -> some View { + if let officialRating = viewModel.item.officialRating { + AttributeBadge( + style: .outline, + title: officialRating + ) + } + } + + @ViewBuilder + private func VideoQuality() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + if mediaStreams.has4KVideo { + AttributeBadge( + style: .fill, + title: "4K" + ) + } else if mediaStreams.hasHDVideo { + AttributeBadge( + style: .fill, + title: "HD" + ) + } + if mediaStreams.hasDolbyVision { + AttributeBadge( + style: .fill, + title: "DV" + ) + } + if mediaStreams.hasHDRVideo { + AttributeBadge( + style: .fill, + title: "HDR" + ) + } + } + } + + @ViewBuilder + private func AudioChannels() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + if mediaStreams.has51AudioChannelLayout { + AttributeBadge( + style: .fill, + title: "5.1" + ) + } + if mediaStreams.has71AudioChannelLayout { + AttributeBadge( + style: .fill, + title: "7.1" + ) + } + } + } + + @ViewBuilder + private func Subtitles() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, + mediaStreams.hasSubtitles + { + AttributeBadge( + style: .outline, + title: "CC" + ) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift new file mode 100644 index 00000000..653b6363 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift @@ -0,0 +1,33 @@ +// +// 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 CastAndCrewHStack: View { + + @Router + private var router + + let people: [BaseItemPerson] + + var body: some View { + PosterHStack( + title: L10n.castAndCrew, + type: .portrait, + items: people.filter { person in + person.type?.isSupported ?? false + } + ) { person in + router.route(to: .item(item: .init(person: person))) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift new file mode 100644 index 00000000..8cd1100c --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -0,0 +1,95 @@ +// +// 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 SeriesEpisodeSelector { + + struct EpisodeCard: View { + + @Router + private var router + + let episode: BaseItemDto + + @FocusState + private var isFocused: Bool + + @ViewBuilder + private var overlayView: some View { + ZStack { + if let progressLabel = episode.progressLabel { + LandscapePosterProgressBar( + title: progressLabel, + progress: (episode.userData?.playedPercentage ?? 0) / 100 + ) + } else if episode.userData?.isPlayed ?? false { + ZStack(alignment: .bottomTrailing) { + Color.clear + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 30, height: 30, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black) + .padding() + } + } + + if isFocused { + Image(systemName: "play.fill") + .resizable() + .frame(width: 50, height: 50) + .foregroundStyle(.secondary) + } + } + } + + private var episodeContent: String { + if episode.isUnaired { + episode.airDateLabel ?? L10n.noOverviewAvailable + } else { + episode.overview ?? L10n.noOverviewAvailable + } + } + + var body: some View { + VStack(alignment: .leading) { + Button { + guard let mediaSource = episode.mediaSources?.first else { return } +// router.route(to: .videoPlayer(manager: OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource))) + } label: { + ZStack { + Color.clear + + ImageView(episode.imageSource(.primary, maxWidth: 500)) + .failure { + SystemImageContentView(systemName: episode.systemImage) + } + + overlayView + } + .posterStyle(.landscape) + } + .buttonStyle(.card) + .posterShadow() + .focused($isFocused) + + SeriesEpisodeSelector.EpisodeContent( + subHeader: episode.episodeLocator ?? .emptyDash, + header: episode.displayTitle, + content: episodeContent + ) + .onSelect { + router.route(to: .item(item: episode)) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift new file mode 100644 index 00000000..4f2c434c --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift @@ -0,0 +1,92 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeContent: View { + + @Default(.accentColor) + private var accentColor + + private var onSelect: () -> Void + + let subHeader: String + let header: String + let content: String + + @ViewBuilder + private var subHeaderView: some View { + Text(subHeader) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + @ViewBuilder + private var headerView: some View { + Text(header) + .font(.footnote) + .foregroundColor(.primary) + .lineLimit(1) + .multilineTextAlignment(.leading) + .padding(.bottom, 1) + } + + @ViewBuilder + private var contentView: some View { + Text(content) + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3, reservesSpace: true) + .font(.caption.weight(.light)) + } + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading, spacing: 8) { + subHeaderView + + headerView + + contentView + .frame(maxWidth: .infinity, alignment: .leading) + + L10n.seeMore.text + .font(.caption.weight(.light)) + .foregroundStyle(accentColor) + } + .padding() + } + .buttonStyle(.card) + } + } +} + +extension SeriesEpisodeSelector.EpisodeContent { + init( + subHeader: String, + header: String, + content: String + ) { + self.subHeader = subHeader + self.header = header + self.content = content + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift new file mode 100644 index 00000000..f04f00b7 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift @@ -0,0 +1,205 @@ +// +// 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 CollectionHStack +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeHStack: View { + + @EnvironmentObject + private var focusGuide: FocusGuide + + @FocusState + private var focusedEpisodeID: String? + + @ObservedObject + var viewModel: SeasonItemViewModel + + @State + private var didScrollToPlayButtonItem = false + @State + private var lastFocusedEpisodeID: String? + + @StateObject + private var proxy = CollectionHStackProxy() + + let playButtonItem: BaseItemDto? + + // MARK: - Content View + + private func contentView(viewModel: SeasonItemViewModel) -> some View { + CollectionHStack( + uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, + columns: 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + .focused($focusedEpisodeID, equals: episode.id) + .padding(.horizontal, 4) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .proxy(proxy) + .onFirstAppear { + guard !didScrollToPlayButtonItem else { return } + didScrollToPlayButtonItem = true + + lastFocusedEpisodeID = playButtonItem?.id + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let playButtonItem else { return } + proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) + } + } + } + + // MARK: - Determine Which Episode should be Focused + + private func getContentFocus() { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + /// Focus the EmptyCard if the Season has no elements + focusedEpisodeID = "emptyCard" + } else { + if let lastFocusedEpisodeID, + viewModel.elements.contains(where: { $0.id == lastFocusedEpisodeID }) + { + /// Return focus to the Last Focused Episode if it exists in the current Season + focusedEpisodeID = lastFocusedEpisodeID + } else { + /// Focus the First Episode in the season as a last resort + focusedEpisodeID = viewModel.elements.first?.id + } + } + case .error: + /// Focus the ErrorCard if the Season failed to load + focusedEpisodeID = "errorCard" + case .initial, .refreshing: + /// Focus the LoadingCard if the Season is currently loading + focusedEpisodeID = "loadingCard" + } + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + EmptyHStack(focusedEpisodeID: $focusedEpisodeID) + } else { + contentView(viewModel: viewModel) + } + case let .error(error): + ErrorHStack(viewModel: viewModel, error: error, focusedEpisodeID: $focusedEpisodeID) + case .initial, .refreshing: + LoadingHStack(focusedEpisodeID: $focusedEpisodeID) + } + } + .padding(.bottom, 45) + .focusSection() + .focusGuide( + focusGuide, + tag: "episodes", + onContentFocus: { + getContentFocus() + }, + top: "belowHeader" + ) + .onChange(of: viewModel.id) { + lastFocusedEpisodeID = viewModel.elements.first?.id + } + .onChange(of: focusedEpisodeID) { _, newValue in + guard let newValue else { return } + lastFocusedEpisodeID = newValue + } + .onChange(of: viewModel.state) { _, newValue in + if newValue == .content { + lastFocusedEpisodeID = viewModel.elements.first?.id + } + } + } + } + + // MARK: - Empty HStack + + struct EmptyHStack: View { + + let focusedEpisodeID: FocusState.Binding + + var body: some View { + CollectionHStack( + count: 1, + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.EmptyCard() + .focused(focusedEpisodeID, equals: "emptyCard") + .padding(.horizontal, 4) + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } + + // MARK: - Error HStack + + struct ErrorHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + let error: JellyfinAPIError + let focusedEpisodeID: FocusState.Binding + + var body: some View { + CollectionHStack( + count: 1, + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.ErrorCard(error: error) + .onSelect { + viewModel.send(.refresh) + } + .focused(focusedEpisodeID, equals: "errorCard") + .padding(.horizontal, 4) + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } + + // MARK: - Loading HStack + + struct LoadingHStack: View { + + let focusedEpisodeID: FocusState.Binding + + var body: some View { + CollectionHStack( + count: 1, + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.LoadingCard() + .focused(focusedEpisodeID, equals: "loadingCard") + .padding(.horizontal, 4) + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift new file mode 100644 index 00000000..7b365deb --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift @@ -0,0 +1,117 @@ +// +// 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 SeriesEpisodeSelector { + + struct SeasonsHStack: View { + + // MARK: - Environment & Observed Objects + + @EnvironmentObject + private var focusGuide: FocusGuide + + @ObservedObject + var viewModel: SeriesItemViewModel + + // MARK: - Selection Binding + + @Binding + var selection: SeasonItemViewModel.ID? + + // MARK: - Focus Variables + + @FocusState + private var focusedSeason: SeasonItemViewModel.ID? + + @State + private var didScrollToPlayButtonSeason = false + + // MARK: - Body + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: EdgeInsets.edgePadding / 2) { + ForEach(viewModel.seasons) { season in + seasonButton(season: season) + .id(season.id) + } + } + .padding(.horizontal, EdgeInsets.edgePadding) + } + .padding(.bottom, 45) + .focusSection() + .focusGuide( + focusGuide, + tag: "belowHeader", + onContentFocus: { focusedSeason = selection }, + top: "header", + bottom: "episodes" + ) + .mask { + VStack(spacing: 0) { + Color.white + + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 20) + } + } + .onChange(of: focusedSeason) { _, newValue in + if let newValue = newValue { + selection = newValue + } + } + .onFirstAppear { + guard !didScrollToPlayButtonSeason else { return } + didScrollToPlayButtonSeason = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let selection else { return } + + proxy.scrollTo(selection) + } + } + } + .scrollClipDisabled() + } + + // MARK: - Season Button + + @ViewBuilder + private func seasonButton(season: SeasonItemViewModel) -> some View { + Button { + selection = season.id + } label: { + Marquee(season.season.displayTitle, animateWhenFocused: true) + .frame(maxWidth: 300) + .font(.headline) + .fontWeight(.semibold) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .if(selection == season.id) { text in + text + .background(.white) + .foregroundColor(.black) + } + } + .focused($focusedSeason, equals: season.id) + .buttonStyle(.card) + .padding(.horizontal, 4) + .padding(.vertical) + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/Components/SubtitleSearchRow.swift b/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/Components/SubtitleSearchRow.swift new file mode 100644 index 00000000..1202ce77 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/Components/SubtitleSearchRow.swift @@ -0,0 +1,68 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +extension ItemSubtitleSearchView { + + struct SubtitleResultRow: View { + + // MARK: - Environment Variables + + @Environment(\.isSelected) + var isSelected + + // MARK: - Subtitle Variable + + let subtitle: RemoteSubtitleInfo + + // MARK: - Subtitle Action + + let onSelect: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(subtitle.name ?? L10n.unknown) + .font(.headline) + .fontWeight(.semibold) + + LabeledContent(L10n.language, value: subtitle.threeLetterISOLanguageName ?? L10n.unknown) + + if let downloadCount = subtitle.downloadCount { + LabeledContent(L10n.downloads, value: downloadCount.description) + } + + if let rating = subtitle.communityRating { + LabeledContent(L10n.communityRating, value: String(format: "%.1f", rating)) + } + + if let author = subtitle.author { + LabeledContent(L10n.author, value: author) + } + + if let format = subtitle.format { + LabeledContent(L10n.format, value: format) + } + } + .foregroundStyle(isSelected ? .primary : .secondary, .secondary) + .font(.caption) + + Spacer() + + ListRowCheckbox() + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/ItemSubtitleSearchView.swift b/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/ItemSubtitleSearchView.swift new file mode 100644 index 00000000..2779a5d3 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ItemSubtitleSearchView/ItemSubtitleSearchView.swift @@ -0,0 +1,185 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ItemSubtitleSearchView: View { + + // MARK: - Accent Color + + @Default(.accentColor) + private var accentColor + + // MARK: - Router + + @Router + private var router + + // MARK: - ViewModel + + @ObservedObject + private var viewModel: SubtitleEditorViewModel + + // MARK: - Selected Subtitles + + @State + private var selectedSubtitles: Set = [] + + // MARK: - Search Properties + + /// Default to user's language + @State + private var language: String? = Locale.current.language.languageCode?.identifier(.alpha3) + @State + private var isPerfectMatch = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: SubtitleEditorViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + ZStack { + BlurView() + .ignoresSafeArea() + contentView + } + .navigationTitle(L10n.search) + .onFirstAppear { + viewModel.send(.search(language: language)) + } + .topBarTrailing { + if viewModel.backgroundStates.isNotEmpty { + ProgressView() + } + } + .onReceive(viewModel.events) { event in + switch event { + case .deleted: + return + case .uploaded: + router.dismiss() + case let .error(eventError): + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + switch viewModel.state { + case .initial, .content: + searchView + case let .error(error): + ErrorView(error: error) + } + } + + // MARK: - Search View + + private var searchView: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "textformat") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + searchSection + resultsSection + } + } + + // MARK: - Search Section + + @ViewBuilder + private var searchSection: some View { + Section(L10n.options) { + CulturePicker(L10n.language, threeLetterISOLanguageName: $language) + .onChange(of: language) { + guard let language else { return } + viewModel.send(.search(language: language, isPerfectMatch: isPerfectMatch)) + } + + Toggle(L10n.perfectMatch, isOn: $isPerfectMatch) + .onChange(of: isPerfectMatch) { + guard let language else { return } + viewModel.send(.search(language: language, isPerfectMatch: isPerfectMatch)) + } + } + + Section { + if viewModel.backgroundStates.contains(.updating) { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + } + .listRowInsets(.zero) + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.save) { + setSubtitles() + } + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .listRowInsets(.zero) + .disabled(selectedSubtitles.isEmpty) + .opacity(selectedSubtitles.isEmpty ? 0.5 : 1) + } + } + } + + // MARK: - Results Section + + private var resultsSection: some View { + Section(L10n.search) { + if viewModel.searchResults.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + ForEach(viewModel.searchResults, id: \.id) { subtitle in + let isSelected = subtitle.id.map { selectedSubtitles.contains($0) } ?? false + + SubtitleResultRow(subtitle: subtitle) { + guard let subtitleID = subtitle.id else { return } + selectedSubtitles.toggle(value: subtitleID) + } + .foregroundStyle(isSelected ? .primary : .secondary, .secondary) + .isSelected(isSelected) + .isEditing(true) + } + } + } + + // MARK: - Set Subtitles + + private func setSubtitles() { + guard selectedSubtitles.isNotEmpty else { + error = JellyfinAPIError(L10n.noItemSelected) + return + } + + viewModel.send(.set(selectedSubtitles)) + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift b/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift new file mode 100644 index 00000000..76299702 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift @@ -0,0 +1,84 @@ +// +// 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 + +// TODO: have items provide labeled attributes +// TODO: don't layout `VStack` if no data + +extension ItemView { + + struct OverviewView: View { + + let item: BaseItemDto + private var overviewLineLimit: Int? + private var taglineLineLimit: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let birthday = item.birthday?.formatted(date: .long, time: .omitted) { + LabeledContent( + L10n.born, + value: birthday + ) + } + + if let deathday = item.deathday?.formatted(date: .long, time: .omitted) { + LabeledContent( + L10n.died, + value: deathday + ) + } + + if let birthplace = item.birthplace { + LabeledContent( + L10n.birthplace, + value: birthplace + ) + } + + if let firstTagline = item.taglines?.first { + Text(firstTagline) + .font(.subheadline) + .fontWeight(.bold) + .multilineTextAlignment(.leading) + .lineLimit(taglineLineLimit) + } + + if let itemOverview = item.overview { + Text(itemOverview) + .font(.subheadline) + .lineLimit(overviewLineLimit) + } + } + .font(.footnote) + .labeledContentStyle(.itemAttribute) + } + } +} + +extension ItemView.OverviewView { + + init(item: BaseItemDto) { + self.init( + item: item, + overviewLineLimit: nil, + taglineLineLimit: nil + ) + } + + func overviewLineLimit(_ limit: Int) -> Self { + copy(modifying: \.overviewLineLimit, with: limit) + } + + func taglineLineLimit(_ limit: Int) -> Self { + copy(modifying: \.taglineLineLimit, with: limit) + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift rename to Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift new file mode 100644 index 00000000..e6b92b17 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift @@ -0,0 +1,161 @@ +// +// 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 Logging +import SwiftUI + +extension ItemView { + + struct PlayButton: View { + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + @FocusState + private var isFocused: Bool + + private let logger = Logger.swiftfin() + + // MARK: - Media Sources + + private var mediaSources: [MediaSourceInfo] { + viewModel.playButtonItem?.mediaSources ?? [] + } + + // MARK: - Multiple Media Sources + + private var multipleVersions: Bool { + mediaSources.count > 1 + } + + // MARK: - Validation + + private var isEnabled: Bool { + viewModel.selectedMediaSource != nil + } + + // MARK: - Title + + private var title: String { + /// Use the Season/Episode label for the Series ItemView + if let seriesViewModel = viewModel as? SeriesItemViewModel, + let seasonEpisodeLabel = seriesViewModel.playButtonItem?.seasonEpisodeLabel + { + return seasonEpisodeLabel + + /// Use a Play/Resume label for single Media Source items that are not Series + } else if let playButtonLabel = viewModel.playButtonItem?.playButtonLabel { + return playButtonLabel + + /// Fallback to a generic `Play` label + } else { + return L10n.play + } + } + + // MARK: - Media Source + + private var source: String? { + guard let sourceLabel = viewModel.selectedMediaSource?.displayTitle, + viewModel.item.mediaSources?.count ?? 0 > 1 + else { + return nil + } + + return sourceLabel + } + + // 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 { + play() + } label: { + HStack(spacing: 15) { + Image(systemName: "play.fill") + .font(.title3) + .padding(.trailing, 4) + + VStack(alignment: .leading) { + Text(title) + .fontWeight(.semibold) + + if let source { + Marquee(source, animateWhenFocused: true) + .font(.caption) + .frame(maxWidth: 250) + } + } + } + .foregroundStyle(isEnabled ? .black : Color(UIColor.secondaryLabel)) + .padding(20) + .frame(width: multipleVersions ? 320 : 440, height: 100, alignment: .center) + .background { + if isFocused { + isEnabled ? Color.white : Color.secondarySystemFill + } else { + Color.white + .opacity(0.5) + } + } + .cornerRadius(10) + } + .buttonStyle(.card) + .contextMenu { + if viewModel.playButtonItem?.userData?.playbackPositionTicks != 0 { + Button(L10n.playFromBeginning, systemImage: "gobackward") { + play(fromBeginning: true) + } + } + } + .disabled(!isEnabled) + .focused($isFocused) + } + + // MARK: - Play Content + + private func play(fromBeginning: Bool = false) { + guard var playButtonItem = viewModel.playButtonItem, + let selectedMediaSource = viewModel.selectedMediaSource + else { + logger.error("Play selected with no item or media source") + return + } + + if fromBeginning { + playButtonItem.userData?.playbackPositionTicks = 0 + } + + let manager = MediaPlayerManager( + item: playButtonItem +// queue: EpisodeMediaPlayerQueue(episode: playButtonItem) + ) { item in + try await MediaPlayerItem.build(for: item, mediaSource: selectedMediaSource) + } + + router.route(to: .videoPlayer(manager: manager)) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift new file mode 100644 index 00000000..05ff1533 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift @@ -0,0 +1,40 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct SimilarItemsHStack: View { + + @Default(.Customization.similarPosterType) + private var similarPosterType + + @Router + private var router + + @StateObject + private var viewModel: PagingLibraryViewModel + + init(items: [BaseItemDto]) { + self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) + } + + var body: some View { + PosterHStack( + title: L10n.recommended, + type: similarPosterType, + items: viewModel.elements + ) { item in + router.route(to: .item(item: item)) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift new file mode 100644 index 00000000..d00f93ec --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift @@ -0,0 +1,35 @@ +// +// 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 SpecialFeaturesHStack: View { + + @Router + private var router + + let items: [BaseItemDto] + + var body: some View { + PosterHStack( + title: L10n.specialFeatures, + type: .landscape, + items: items + ) { item in + guard let mediaSource = item.mediaSources?.first else { return } +// router.route( +// to: .videoPlayer(manager: OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) +// ) + } + .posterOverlay(for: BaseItemDto.self) { _ in EmptyView() } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift new file mode 100644 index 00000000..eeac5f3a --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -0,0 +1,100 @@ +// +// 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 + +struct ItemView: View { + + protocol ScrollContainerView: View { + + associatedtype Content: View + + init(viewModel: ItemViewModel, content: @escaping () -> Content) + } + + @StateObject + private var viewModel: ItemViewModel + + // MARK: typeViewModel + + private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { + switch item.type { + case .boxSet, .person, .musicArtist: + return CollectionItemViewModel(item: item) + case .episode: + return EpisodeItemViewModel(item: item) + case .movie: + return MovieItemViewModel(item: item) + case .musicVideo, .video: + return ItemViewModel(item: item) + case .series: + return SeriesItemViewModel(item: item) + default: + assertionFailure("Unsupported item") + return ItemViewModel(item: item) + } + } + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) + } + + @ViewBuilder + private var scrollContentView: some View { + switch viewModel.item.type { + case .boxSet, .person, .musicArtist: + CollectionItemContentView(viewModel: viewModel as! CollectionItemViewModel) + case .episode, .musicVideo, .video: + SimpleItemContentView(viewModel: viewModel) + case .movie: + MovieItemContentView(viewModel: viewModel as! MovieItemViewModel) + case .series: + SeriesItemContentView(viewModel: viewModel as! SeriesItemViewModel) + default: + Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) + } + } + + // MARK: scrollContainerView + + private func scrollContainerView( + viewModel: ItemViewModel, + content: @escaping () -> Content + ) -> any ScrollContainerView { + CinematicScrollView(viewModel: viewModel, content: content) + } + + @ViewBuilder + private var innerBody: some View { + scrollContainerView(viewModel: viewModel) { + scrollContentView + } + .eraseToAnyView() + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + innerBody + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .onFirstAppear { + viewModel.send(.refresh) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift new file mode 100644 index 00000000..4c10a7ea --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct MovieItemContentView: View { + + @ObservedObject + var viewModel: MovieItemViewModel + + var body: some View { + VStack(spacing: 0) { + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + if viewModel.specialFeatures.isNotEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + } + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift new file mode 100644 index 00000000..cbad258e --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -0,0 +1,225 @@ +// +// 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 CinematicScrollView: ScrollContainerView { + + @ObservedObject + private var viewModel: ItemViewModel + + @StateObject + private var focusGuide = FocusGuide() + + private let content: Content + + init( + viewModel: ItemViewModel, + content: @escaping () -> Content + ) { + self.viewModel = viewModel + self.content = content() + } + + private func withBackgroundImageSource( + @ViewBuilder content: @escaping (ImageSource) -> some View + ) -> some View { + let item: BaseItemDto + + if viewModel.item.type == .person || viewModel.item.type == .musicArtist, + let typeViewModel = viewModel as? CollectionItemViewModel, + let randomItem = typeViewModel.randomItem() + { + item = randomItem + } else { + item = viewModel.item + } + + let imageType: ImageType = { + switch item.type { + case .episode, .musicVideo, .video: + .primary + default: + .backdrop + } + }() + + let imageSource = item.imageSource(imageType, maxWidth: 1920) + + return content(imageSource) + .id(imageSource.url?.hashValue) + .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) + } + + var body: some View { + GeometryReader { proxy in + ZStack { + withBackgroundImageSource { imageSource in + ImageView(imageSource) + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + CinematicHeaderView(viewModel: viewModel) + .ifLet(viewModel as? SeriesItemViewModel) { view, _ in + view + .focusGuide( + focusGuide, + tag: "header", + bottom: "belowHeader" + ) + } + .frame(height: proxy.size.height - 150) + .padding(.bottom, 50) + + content + } + .background { + BlurView(style: .dark) + .mask { + VStack(spacing: 0) { + LinearGradient(gradient: Gradient(stops: [ + .init(color: .white, location: 0), + .init(color: .white.opacity(0.7), location: 0.4), + .init(color: .white.opacity(0), location: 1), + ]), startPoint: .bottom, endPoint: .top) + .frame(height: proxy.size.height - 150) + + Color.white + } + } + } + .environmentObject(focusGuide) + } + } + } + .ignoresSafeArea() + } + } +} + +extension ItemView { + + struct CinematicHeaderView: View { + + enum CinematicHeaderFocusLayer: Hashable { + case top + case playButton + case actionButtons + } + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @Router + private var router + @ObservedObject + var viewModel: ItemViewModel + @FocusState + private var focusedLayer: CinematicHeaderFocusLayer? + + var body: some View { + VStack(alignment: .leading) { + + Color.clear + .focusable() + .focused($focusedLayer, equals: .top) + + HStack(alignment: .bottom) { + + VStack(alignment: .leading, spacing: 20) { + + ImageView(viewModel.item.imageSource( + .logo, + maxHeight: 250 + )) + .placeholder { _ in + EmptyView() + } + .failure { + Marquee(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundStyle(.white) + } + .aspectRatio(contentMode: .fit) + .padding(.bottom) + + OverviewView(item: viewModel.item) + .taglineLineLimit(1) + .overviewLineLimit(3) + + if viewModel.item.type != .person { + HStack { + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel + ) + } + } + } + + Spacer() + + VStack { + if viewModel.item.type == .person || viewModel.item.type == .musicArtist { + ImageView(viewModel.item.imageSource(.primary, maxWidth: 440)) + .failure { + SystemImageContentView(systemName: viewModel.item.systemImage) + } + .posterStyle(.portrait, contentMode: .fill) + .frame(width: 440) + .cornerRadius(10) + .accessibilityIgnoresInvertColors() + } else if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .focused($focusedLayer, equals: .playButton) + } + + ItemView.ActionButtonHStack(viewModel: viewModel) + .focused($focusedLayer, equals: .actionButtons) + .frame(width: 440) + } + .frame(width: 450) + .padding(.leading, 150) + } + } + .padding(.horizontal, 50) + .onChange(of: focusedLayer) { _, layer in + if layer == .top { + if viewModel.item.presentPlayButton { + focusedLayer = .playButton + } else { + focusedLayer = .actionButtons + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift new file mode 100644 index 00000000..5f69446e --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift @@ -0,0 +1,40 @@ +// +// 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 SeriesItemContentView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + VStack(spacing: 0) { + if viewModel.seasons.isNotEmpty { + SeriesEpisodeSelector(viewModel: viewModel) + } + + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + if viewModel.specialFeatures.isNotEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + } + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift new file mode 100644 index 00000000..bf088283 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift @@ -0,0 +1,28 @@ +// +// 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 SimpleItemContentView: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(spacing: 0) { + if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin tvOS/Views/LearnMoreModal.swift b/Swiftfin tvOS/Views/LearnMoreModal.swift new file mode 100644 index 00000000..287ec47d --- /dev/null +++ b/Swiftfin tvOS/Views/LearnMoreModal.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct LearnMoreModal: View { + + private let content: AnyView + + // MARK: - Initializer + + init(@LabeledContentBuilder content: () -> AnyView) { + self.content = content() + } + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + content + .labeledContentStyle(LearnMoreLabeledContentStyle()) + .foregroundStyle(Color.primary, Color.secondary) + } + .padding(24) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(Material.regular) + } + .padding() + } +} diff --git a/Swiftfin tvOS/Views/MediaSourceInfoView.swift b/Swiftfin tvOS/Views/MediaSourceInfoView.swift new file mode 100644 index 00000000..8be39959 --- /dev/null +++ b/Swiftfin tvOS/Views/MediaSourceInfoView.swift @@ -0,0 +1,136 @@ +// +// 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 + +struct MediaSourceInfoView: View { + + @FocusState + private var selectedMediaStream: MediaStream? + + @State + private var lastSelectedMediaStream: MediaStream? + + let source: MediaSourceInfo + + @ViewBuilder + private var content: some View { + GeometryReader { proxy in + VStack(alignment: .center) { + + Text(source.displayTitle) + .font(.title) + .frame(maxHeight: proxy.size.height * 0.33) + + HStack { + Form { + if let videoStreams = source.videoStreams, + videoStreams.isNotEmpty + { + Section(L10n.video) { + ForEach(videoStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + + if let audioStreams = source.audioStreams, + audioStreams.isNotEmpty + { + Section(L10n.audio) { + ForEach(audioStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + + if let subtitleStreams = source.subtitleStreams, + subtitleStreams.isNotEmpty + { + Section(L10n.subtitle) { + ForEach(subtitleStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + } + + Form { + if let lastSelectedMediaStream { + Section { + ForEach(lastSelectedMediaStream.metadataProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + + if lastSelectedMediaStream.colorProperties.isNotEmpty { + Section(L10n.color) { + ForEach(lastSelectedMediaStream.colorProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + } + + if lastSelectedMediaStream.deliveryProperties.isNotEmpty { + Section(L10n.delivery) { + ForEach(lastSelectedMediaStream.deliveryProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + } + } else { + Button { + L10n.none.text + } + } + } + } + .padding(.horizontal) + } + .frame(maxWidth: .infinity) + } + .onChange(of: selectedMediaStream) { _, newValue in + guard let newValue else { return } + lastSelectedMediaStream = newValue + } + } + + var body: some View { + ZStack { + BlurView() + + content + } + .ignoresSafeArea() + } +} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift new file mode 100644 index 00000000..65455864 --- /dev/null +++ b/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift @@ -0,0 +1,130 @@ +// +// 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 JellyfinAPI +import SwiftUI + +private let landscapeMaxWidth: CGFloat = 110 +private let portraitMaxWidth: CGFloat = 60 + +extension PagingLibraryView { + + struct LibraryRow: View { + + private let item: Element + private var action: () -> Void + private let posterType: PosterDisplayType + + init( + item: Element, + posterType: PosterDisplayType, + action: @escaping () -> Void + ) { + self.item = item + self.action = action + self.posterType = posterType + } + + private func imageSources(from element: Element) -> [ImageSource] { + switch posterType { + case .landscape: + element.landscapeImageSources(maxWidth: landscapeMaxWidth, quality: 90) + case .portrait: + element.portraitImageSources(maxWidth: portraitMaxWidth, quality: 90) + case .square: + element.squareImageSources(maxWidth: portraitMaxWidth, quality: 90) + } + } + + @ViewBuilder + private func itemAccessoryView(item: BaseItemDto) -> some View { + DotHStack { + if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { + Text(seasonEpisodeLocator) + } else if let premiereYear = item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = item.runTimeLabel { + Text(runtime) + } + + if let officialRating = item.officialRating { + Text(officialRating) + } + } + } + + @ViewBuilder + private func personAccessoryView(person: BaseItemPerson) -> some View { + if let subtitle = person.subtitle { + Text(subtitle) + } + } + + @ViewBuilder + private var accessoryView: some View { + switch item { + case let element as BaseItemDto: + itemAccessoryView(item: element) + case let element as BaseItemPerson: + personAccessoryView(person: element) + default: + AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") + } + } + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(posterType == .landscape ? .subheadline : .callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + accessoryView + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + } + Spacer() + } + } + + @ViewBuilder + private var rowLeading: some View { + ZStack { + Color.clear + + ImageView(imageSources(from: item)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .posterStyle(posterType) + .frame(width: posterType == .landscape ? 110 : 60) + .posterShadow() + .padding(.vertical, 8) + } + + // MARK: body + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + rowContent + } + .onSelect(perform: action) + .focusedValue(\.focusedPoster, AnyPoster(item)) + } + } +} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift b/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift new file mode 100644 index 00000000..879c7e22 --- /dev/null +++ b/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift @@ -0,0 +1,78 @@ +// +// 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 + +// TODO: come up with better name along with `ListRowButton` + +// Meant to be used when making a custom list without `List` or `Form` +struct ListRow: View { + + @State + private var contentSize: CGSize = .zero + + private let leading: Leading + private let content: Content + private var action: () -> Void + private var insets: EdgeInsets + private var isSeparatorVisible: Bool + + var body: some View { + ZStack(alignment: .bottomTrailing) { + + Button { + action() + } label: { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { + + leading + + content + .frame(maxHeight: .infinity) + .trackingSize($contentSize) + } + .padding(.top, insets.top) + .padding(.bottom, insets.bottom) + .padding(.leading, insets.leading) + .padding(.trailing, insets.trailing) + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.plain) + + Color.secondarySystemFill + .frame(width: contentSize.width, height: 1) + .padding(.trailing, insets.trailing) + .isVisible(isSeparatorVisible) + } + } +} + +extension ListRow { + + init( + insets: EdgeInsets = .zero, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + leading: leading(), + content: content(), + action: {}, + insets: insets, + isSeparatorVisible: true + ) + } + + func isSeparatorVisible(_ isVisible: Bool) -> Self { + copy(modifying: \.isSeparatorVisible, with: isVisible) + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.action, with: action) + } +} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift new file mode 100644 index 00000000..fb7f55eb --- /dev/null +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -0,0 +1,405 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: Figure out proper tab bar handling with the collection offset +// TODO: fix paging for next item focusing the tab + +struct PagingLibraryView: View { + + @Default(.Customization.Library.cinematicBackground) + private var cinematicBackground + @Default(.Customization.Library.enabledDrawerFilters) + private var enabledDrawerFilters + @Default(.Customization.Library.rememberLayout) + private var rememberLayout + + @Default(.Customization.Library.displayType) + private var defaultDisplayType: LibraryDisplayType + @Default(.Customization.Library.listColumnCount) + private var defaultListColumnCount: Int + @Default(.Customization.Library.posterType) + private var defaultPosterType: PosterDisplayType + + @FocusedValue(\.focusedPoster) + private var focusedPoster + + @Router + private var router + + @State + private var presentBackground = false + @State + private var layout: CollectionVGridLayout + @State + private var safeArea: EdgeInsets = .zero + + @StoredValue + private var displayType: LibraryDisplayType + @StoredValue + private var listColumnCount: Int + @StoredValue + private var posterType: PosterDisplayType + + @StateObject + private var collectionVGridProxy: CollectionVGridProxy = .init() + @StateObject + private var viewModel: PagingLibraryViewModel + + @StateObject + private var cinematicBackgroundProxy: CinematicBackgroundView.Proxy = .init() + + init(viewModel: PagingLibraryViewModel) { + + self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) + self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) + self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) + + self._viewModel = StateObject(wrappedValue: viewModel) + + let defaultDisplayType = Defaults[.Customization.Library.displayType] + let defaultListColumnCount = Defaults[.Customization.Library.listColumnCount] + let defaultPosterType = Defaults[.Customization.Library.posterType] + + let displayType = StoredValues[.User.libraryDisplayType(parentID: viewModel.parent?.id)] + let listColumnCount = StoredValues[.User.libraryListColumnCount(parentID: viewModel.parent?.id)] + let posterType = StoredValues[.User.libraryPosterType(parentID: viewModel.parent?.id)] + + let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType + let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? listColumnCount : defaultListColumnCount + let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType + + self._layout = State( + initialValue: Self.makeLayout( + posterType: initialPosterType, + viewType: initialDisplayType, + listColumnCount: initialListColumnCount + ) + ) + } + + // MARK: On Select + + private func onSelect(_ element: Element) { + switch element { + case let element as BaseItemDto: + select(item: element) + case let element as BaseItemPerson: + select(item: BaseItemDto(person: element)) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + + private func select(item: BaseItemDto) { + switch item.type { + case .collectionFolder, .folder: + let viewModel = ItemLibraryViewModel(parent: item, filters: .default) + router.route(to: .library(viewModel: viewModel)) + default: + router.route(to: .item(item: item)) + } + } + + // MARK: Select Person + + private func select(person: BaseItemPerson) { + let viewModel = ItemLibraryViewModel(parent: person) + router.route(to: .library(viewModel: viewModel)) + } + + // MARK: Make Layout + + private static func makeLayout( + posterType: PosterDisplayType, + viewType: LibraryDisplayType, + listColumnCount: Int + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + return .columns(5, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + case (.portrait, .grid), (.square, .grid): + return .columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + case (_, .list): + return .columns(listColumnCount, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + } + } + + // MARK: Set Default Layout + + private func setDefaultLayout() { + layout = Self.makeLayout( + posterType: defaultPosterType, + viewType: defaultDisplayType, + listColumnCount: defaultListColumnCount + ) + } + + // MARK: Set Custom Layout + + private func setCustomLayout() { + layout = Self.makeLayout( + posterType: posterType, + viewType: displayType, + listColumnCount: listColumnCount + ) + } + + // MARK: Set Cinematic Background + + private func setCinematicBackground() { + guard let focusedPoster else { + withAnimation { + presentBackground = false + } + return + } + + cinematicBackgroundProxy.select(item: focusedPoster) + + if !presentBackground { + withAnimation { + presentBackground = true + } + } + } + + // MARK: Landscape Grid Item View + + private func landscapeGridItemView(item: Element) -> some View { + PosterButton( + item: item, + type: .landscape + ) { + onSelect(item) + } label: { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } else if viewModel.parent?.libraryType == .folder { + PosterButton.TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + .hidden() + } + } + } + + // MARK: Portrait Grid Item View + + @ViewBuilder + private func portraitGridItemView(item: Element) -> some View { + PosterButton( + item: item, + type: .portrait + ) { + onSelect(item) + } label: { + if item.showTitle { + PosterButton.TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } else if viewModel.parent?.libraryType == .folder { + PosterButton.TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + .hidden() + } + } + } + + // MARK: List Item View + + @ViewBuilder + private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { + LibraryRow( + item: item, + posterType: posterType + ) { + onSelect(item) + } + } + + // MARK: Error View + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + // MARK: Grid View + + @ViewBuilder + private var gridView: some View { + CollectionVGrid( + uniqueElements: viewModel.elements, + layout: layout + ) { item in + + let displayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType + .wrappedValue + let posterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue + + switch (posterType, displayType) { + case (.landscape, .grid): + landscapeGridItemView(item: item) + case (.portrait, .grid), (.square, .grid): + portraitGridItemView(item: item) + case (_, .list): + listItemView(item: item, posterType: posterType) + } + } + .onReachedBottomEdge(offset: .rows(3)) { + viewModel.send(.getNextPage) + } + .proxy(collectionVGridProxy) + .scrollIndicators(.hidden) + } + + // MARK: Inner Content View + + @ViewBuilder + private var innerContent: some View { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + gridView + } + case .initial, .refreshing: + ProgressView() + default: + AssertionFailureView("Expected view for unexpected state") + } + } + + // MARK: Content View + + @ViewBuilder + private var contentView: some View { + + innerContent + // These exist here to alleviate type-checker issues + .onChange(of: posterType) { + setCustomLayout() + } + .onChange(of: displayType) { + setCustomLayout() + } + .onChange(of: listColumnCount) { + setCustomLayout() + } + + // Logic for LetterPicker. Enable when ready + + /* if letterPickerEnabled, let filterViewModel = viewModel.filterViewModel { + ZStack(alignment: letterPickerOrientation.alignment) { + innerContent + .padding(letterPickerOrientation.edge, LetterPickerBar.size + 10) + .frame(maxWidth: .infinity) + + LetterPickerBar(viewModel: filterViewModel) + .padding(.top, safeArea.top) + .padding(.bottom, safeArea.bottom) + .padding(letterPickerOrientation.edge, 10) + } + } else { + innerContent + } + // These exist here to alleviate type-checker issues + .onChange(of: posterType) { + setCustomLayout() + } + .onChange(of: displayType) { + setCustomLayout() + } + .onChange(of: listColumnCount) { + setCustomLayout() + }*/ + } + + // MARK: Body + + var body: some View { + ZStack { + Color.clear + + if cinematicBackground { + CinematicBackgroundView(viewModel: cinematicBackgroundProxy) + .isVisible(presentBackground) + .blurred() + } + + switch viewModel.state { + case .content, .initial, .refreshing: + contentView + case let .error(error): + errorView(with: error) + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .navigationTitle(viewModel.parent?.displayTitle ?? "") + .onChange(of: focusedPoster) { + setCinematicBackground() + } + .onChange(of: rememberLayout) { + if rememberLayout { + setCustomLayout() + } else { + setDefaultLayout() + } + } + .onChange(of: defaultPosterType) { + guard !Defaults[.Customization.Library.rememberLayout] else { return } + setDefaultLayout() + } + .onChange(of: defaultDisplayType) { + guard !Defaults[.Customization.Library.rememberLayout] else { return } + setDefaultLayout() + } + .onChange(of: defaultListColumnCount) { + guard !Defaults[.Customization.Library.rememberLayout] else { return } + setDefaultLayout() + } + .onChange(of: viewModel.filterViewModel?.currentFilters) { _, newValue in + guard let newValue, let id = viewModel.parent?.id else { return } + + if Defaults[.Customization.Library.rememberSort] { + let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] + .mutating(\.sortBy, with: newValue.sortBy) + .mutating(\.sortOrder, with: newValue.sortOrder) + + StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .gotRandomItem(item): + switch item { + case let item as BaseItemDto: + select(item: item) + case let item as BaseItemPerson: + select(item: BaseItemDto(person: item)) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift new file mode 100644 index 00000000..d6e325d5 --- /dev/null +++ b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift @@ -0,0 +1,51 @@ +// +// 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 ProgramsView { + + struct ProgramButtonContent: View { + + let program: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + + Text(program.channelName ?? .emptyDash) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + Text(program.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + HStack(spacing: 2) { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + Text("-") + + if let endDate = program.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/jellypig tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift similarity index 100% rename from jellypig tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift rename to Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift diff --git a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift new file mode 100644 index 00000000..b7d8c506 --- /dev/null +++ b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift @@ -0,0 +1,104 @@ +// +// 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 + +// TODO: background refresh for programs with timer? + +// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program + +struct ProgramsView: View { + + @Router + private var router + + @StateObject + private var programsViewModel = ProgramsViewModel() + + @ViewBuilder + private var contentView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if programsViewModel.recommended.isNotEmpty { + programsSection(title: L10n.onNow, keyPath: \.recommended) + } + + if programsViewModel.series.isNotEmpty { + programsSection(title: L10n.series, keyPath: \.series) + } + + if programsViewModel.movies.isNotEmpty { + programsSection(title: L10n.movies, keyPath: \.movies) + } + + if programsViewModel.kids.isNotEmpty { + programsSection(title: L10n.kids, keyPath: \.kids) + } + + if programsViewModel.sports.isNotEmpty { + programsSection(title: L10n.sports, keyPath: \.sports) + } + + if programsViewModel.news.isNotEmpty { + programsSection(title: L10n.news, keyPath: \.news) + } + } + } + } + + @ViewBuilder + private func programsSection( + title: String, + keyPath: KeyPath + ) -> some View { + PosterHStack( + title: title, + type: .landscape, + items: programsViewModel[keyPath: keyPath] + ) { _ in +// guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } +// router.route( +// to: \.liveVideoPlayer, +// LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) +// ) + } label: { + ProgramButtonContent(program: $0) + } + .posterOverlay(for: BaseItemDto.self) { + ProgramProgressOverlay(program: $0) + } + } + + var body: some View { + ZStack { + switch programsViewModel.state { + case .content: + if programsViewModel.hasNoResults { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + programsViewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: programsViewModel.state) + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + if programsViewModel.state == .initial { + programsViewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift new file mode 100644 index 00000000..81a6751c --- /dev/null +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -0,0 +1,199 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct SearchView: View { + + @Default(.Customization.searchPosterType) + private var searchPosterType + + @Router + private var router + + @State + private var searchQuery = "" + + @StateObject + private var viewModel = SearchViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.search(query: searchQuery) + } + } + + @ViewBuilder + private var suggestionsView: some View { + VStack(spacing: 20) { + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var resultsView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if let movies = viewModel.items[.movie], movies.isNotEmpty { + itemsSection( + title: L10n.movies, + type: .movie, + items: movies, + posterType: searchPosterType + ) + } + + if let series = viewModel.items[.series], series.isNotEmpty { + itemsSection( + title: L10n.tvShows, + type: .series, + items: series, + posterType: searchPosterType + ) + } + + if let collections = viewModel.items[.boxSet], collections.isNotEmpty { + itemsSection( + title: L10n.collections, + type: .boxSet, + items: collections, + posterType: searchPosterType + ) + } + + if let episodes = viewModel.items[.episode], episodes.isNotEmpty { + itemsSection( + title: L10n.episodes, + type: .episode, + items: episodes, + posterType: searchPosterType + ) + } + + if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty { + itemsSection( + title: L10n.musicVideos, + type: .musicVideo, + items: musicVideos, + posterType: .landscape + ) + } + + if let videos = viewModel.items[.video], videos.isNotEmpty { + itemsSection( + title: L10n.videos, + type: .video, + items: videos, + posterType: .landscape + ) + } + + if let programs = viewModel.items[.program], programs.isNotEmpty { + itemsSection( + title: L10n.programs, + type: .program, + items: programs, + posterType: .landscape + ) + } + + if let channels = viewModel.items[.tvChannel], channels.isNotEmpty { + itemsSection( + title: L10n.channels, + type: .tvChannel, + items: channels, + posterType: .square + ) + } + + if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty { + itemsSection( + title: L10n.artists, + type: .musicArtist, + items: musicArtists, + posterType: .portrait + ) + } + + if let people = viewModel.items[.person], people.isNotEmpty { + itemsSection( + title: L10n.people, + type: .person, + items: people, + posterType: .portrait + ) + } + } + .edgePadding(.vertical) + } + } + + private func select(_ item: BaseItemDto) { + switch item.type { + case .program, .tvChannel: + let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession) + router.route(to: .videoPlayer(provider: provider)) + default: + router.route(to: .item(item: item)) + } + } + + @ViewBuilder + private func itemsSection( + title: String, + type: BaseItemKind, + items: [BaseItemDto], + posterType: PosterDisplayType + ) -> some View { + PosterHStack( + title: title, + type: posterType, + items: items, + action: select + ) + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + if viewModel.hasNoResults { + if searchQuery.isEmpty { + suggestionsView + } else { + Text(L10n.noResults) + } + } else { + resultsView + } + case .searching: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + viewModel.getSuggestions() + } + .onChange(of: searchQuery) { _, newValue in + viewModel.search(query: newValue) + } + .searchable(text: $searchQuery, prompt: L10n.search) + } +} diff --git a/jellypig tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift similarity index 100% rename from jellypig tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift rename to Swiftfin tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift diff --git a/jellypig tvOS/Views/SelectUserView/Components/AddUserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserGridButton.swift similarity index 100% rename from jellypig tvOS/Views/SelectUserView/Components/AddUserGridButton.swift rename to Swiftfin tvOS/Views/SelectUserView/Components/AddUserGridButton.swift diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift new file mode 100644 index 00000000..03d21351 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift @@ -0,0 +1,170 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct SelectUserBottomBar: View { + + // MARK: - State & Environment Objects + + @Router + private var router + + @Binding + private var isEditing: Bool + + @Binding + private var serverSelection: SelectUserServerSelection + + // MARK: - Variables + + private let areUsersSelected: Bool + private let hasUsers: Bool + private let selectedServer: ServerState? + private let servers: OrderedSet + + private let onDelete: () -> Void + private let toggleAllUsersSelected: () -> Void + + // MARK: - Initializer + + init( + isEditing: Binding, + serverSelection: Binding, + selectedServer: ServerState?, + servers: OrderedSet, + areUsersSelected: Bool, + hasUsers: Bool, + onDelete: @escaping () -> Void, + toggleAllUsersSelected: @escaping () -> Void + ) { + self._isEditing = isEditing + self._serverSelection = serverSelection + self.areUsersSelected = areUsersSelected + self.hasUsers = hasUsers + self.selectedServer = selectedServer + self.servers = servers + self.onDelete = onDelete + self.toggleAllUsersSelected = toggleAllUsersSelected + } + + // MARK: - Advanced Menu + + @ViewBuilder + private var advancedMenu: some View { + Menu { + Button(L10n.editUsers, systemImage: "person.crop.circle") { + isEditing.toggle() + } + + Divider() + + Button(L10n.advanced, systemImage: "gearshape.fill") { + router.route(to: .appSettings) + } + } label: { + Label(L10n.advanced, systemImage: "gearshape.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .labelStyle(.iconOnly) + .frame(width: 50, height: 50) + } + + // TODO: Do we want to support a grid view and list view like iOS? +// if !viewModel.servers.isEmpty { +// Picker(selection: $userListDisplayType) { +// ForEach(LibraryDisplayType.allCases, id: \.hashValue) { +// Label($0.displayTitle, systemImage: $0.systemImage) +// .tag($0) +// } +// } label: { +// Text(L10n.layout) +// Text(userListDisplayType.displayTitle) +// Image(systemName: userListDisplayType.systemImage) +// } +// .pickerStyle(.menu) +// } + } + + // MARK: - Delete User Button + + @ViewBuilder + private var deleteUsersButton: some View { + ListRowButton( + L10n.delete, + role: .destructive, + action: onDelete + ) + .frame(width: 400, height: 75) + .disabled(!areUsersSelected) + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + HStack(alignment: .top, spacing: 20) { + if isEditing { + deleteUsersButton + + Button { + toggleAllUsersSelected() + } label: { + Text(areUsersSelected ? L10n.removeAll : L10n.selectAll) + .foregroundStyle(Color.primary) + .font(.body.weight(.semibold)) + .frame(width: 200, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + Button { + isEditing = false + } label: { + Text(L10n.cancel) + .foregroundStyle(Color.primary) + .font(.body.weight(.semibold)) + .frame(width: 200, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } else { + AddUserBottomButton( + selectedServer: selectedServer, + servers: servers + ) { server in + router.route(to: .userSignIn(server: server)) + } + .hidden(!hasUsers) + + ServerSelectionMenu( + selection: $serverSelection, + selectedServer: selectedServer, + servers: servers + ) + + advancedMenu + } + } + } + + // MARK: - Body + + var body: some View { + // `Menu` with custom label has some weird additional + // frame/padding that differs from default label style + AlternateLayoutView(alignment: .top) { + Color.clear + .frame(height: 100) + } content: { + contentView + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift new file mode 100644 index 00000000..ca00a838 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -0,0 +1,101 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct ServerSelectionMenu: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + // MARK: - Server Selection + + @Binding + private var serverSelection: SelectUserServerSelection + + private let selectedServer: ServerState? + private let servers: OrderedSet + + // MARK: - Initializer + + init( + selection: Binding, + selectedServer: ServerState?, + servers: OrderedSet + ) { + self._serverSelection = selection + self.selectedServer = selectedServer + self.servers = servers + } + + @ViewBuilder + private var label: some View { + HStack(spacing: 16) { + if let selectedServer { + Image(systemName: "server.rack") + + Text(selectedServer.name) + } else { + Image(systemName: "person.2.fill") + + Text(L10n.allServers) + } + + Image(systemName: "chevron.up.chevron.down") + .foregroundStyle(.secondary) + .font(.subheadline.weight(.semibold)) + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .frame(width: 400, height: 50) + } + + // MARK: - Body + + var body: some View { + Menu { + Picker(L10n.servers, selection: _serverSelection) { + ForEach(servers) { server in + Button { + Text(server.name) + Text(server.currentURL.absoluteString) + } + .tag(SelectUserServerSelection.server(id: server.id)) + } + + if servers.count > 1 { + Label(L10n.allServers, systemImage: "person.2.fill") + .tag(SelectUserServerSelection.all) + } + } + Section { + if let selectedServer { + Button(L10n.editServer, systemImage: "server.rack") { + router.route( + to: .editServer(server: selectedServer, isEditing: true), + style: .sheet + ) + } + } + + Button(L10n.addServer, systemImage: "plus") { + router.route(to: .connectToServer) + } + } + } label: { + label + } + .menuOrder(.fixed) + } + } +} diff --git a/jellypig tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift similarity index 100% rename from jellypig tvOS/Views/SelectUserView/Components/UserGridButton.swift rename to Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift new file mode 100644 index 00000000..319555d5 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -0,0 +1,380 @@ +// +// 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 OrderedCollections +import SwiftUI + +struct SelectUserView: View { + + typealias UserItem = (user: UserState, server: ServerState) + + // MARK: - Defaults + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + @Default(.selectUserServerSelection) + private var serverSelection + + // MARK: - State & Environment Objects + + @Router + private var router + + // MARK: - Select User Variables + + @State + private var isEditingUsers: Bool = false + @State + private var pin: String = "" + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var selectedUsers: Set = [] + + // MARK: - Dialog States + + @State + private var isPresentingConfirmDeleteUsers = false + @State + private var isPresentingLocalPin: Bool = false + + @StateObject + private var viewModel = SelectUserViewModel() + + private var selectedServer: ServerState? { + serverSelection.server(from: viewModel.servers.keys) + } + + private var splashScreenImageSources: [ImageSource] { + switch (serverSelection, selectUserAllServersSplashscreen) { + case (.all, .all): + return viewModel + .servers + .keys + .shuffled() + .map(\.splashScreenImageSource) + + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + guard let server = viewModel + .servers + .keys + .first(where: { $0.id == id }) else { return [] } + + return [server.splashScreenImageSource] + } + } + + private var userItems: [UserItem] { + switch serverSelection { + case .all: + return viewModel.servers + .map { server, users in + users.map { (server: server, user: $0) } + } + .flatMap { $0 } + .sorted(using: \.user.username) + .reversed() + .map { UserItem(user: $0.user, server: $0.server) } + case let .server(id: id): + guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { + return [] + } + + return viewModel.servers[server]! + .sorted(using: \.username) + .map { UserItem(user: $0, server: server) } + } + } + + private func addUserSelected(server: ServerState) { + router.route(to: .userSignIn(server: server)) + } + + private func delete(user: UserState) { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + + // MARK: - Select User(s) + + private func select(user: UserState, needsPin: Bool = true) { + selectedUsers.insert(user) + + switch user.accessPolicy { + case .requireDeviceAuthentication: + // Do nothing, no device authentication on tvOS + break + case .requirePin: + if needsPin { + isPresentingLocalPin = true + return + } + case .none: () + } + + viewModel.signIn(user, pin: pin) + } + + // MARK: - Grid Content View + + @ViewBuilder + private var userGrid: some View { + CenteredLazyVGrid( + data: userItems, + id: \.user.id, + columns: 5, + spacing: EdgeInsets.edgePadding + ) { gridItem in + let user = gridItem.user + let server = gridItem.server + + UserGridButton( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + .isSelected(selectedUsers.contains(user)) + } + } + + @ViewBuilder + private var addUserButtonGrid: some View { + CenteredLazyVGrid( + data: [0], + id: \.self, + columns: 5 + ) { _ in + AddUserGridButton( + selectedServer: selectedServer, + servers: viewModel.servers.keys + ) { server in + router.route(to: .userSignIn(server: server)) + } + } + } + + // MARK: - User View + + @ViewBuilder + private var contentView: some View { + VStack { + ZStack { + Color.clear + + VStack(spacing: 0) { + + Color.clear + .frame(height: 100) + + Group { + if userItems.isEmpty { + addUserButtonGrid + } else { + userGrid + } + } + .focusSection() + } + .scrollIfLargerThanContainer(padding: 100) + .scrollViewOffset($scrollViewOffset) + } + .isEditing(isEditingUsers) + + SelectUserBottomBar( + isEditing: $isEditingUsers, + serverSelection: $serverSelection, + selectedServer: selectedServer, + servers: viewModel.servers.keys, + areUsersSelected: selectedUsers.isNotEmpty, + hasUsers: userItems.isNotEmpty + ) { + isPresentingConfirmDeleteUsers = true + } toggleAllUsersSelected: { + if selectedUsers.isNotEmpty { + selectedUsers.removeAll() + } else { + selectedUsers.insert(contentsOf: userItems.map(\.user)) + } + } + .focusSection() + } + .animation(.linear(duration: 0.1), value: scrollViewOffset) + .environment(\.isOverComplexContent, true) + .background { + if selectUserUseSplashscreen, splashScreenImageSources.isNotEmpty { + ZStack { + ImageView(splashScreenImageSources) + .pipeline(.Swiftfin.local) + .aspectRatio(contentMode: .fill) + .id(splashScreenImageSources) + .transition(.opacity) + .animation(.linear, value: splashScreenImageSources) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } + + // MARK: - Connect to Server View + + @ViewBuilder + private var connectToServerView: some View { + VStack(spacing: 50) { + L10n.connectToJellyfinServerStart.text + .font(.body) + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + + Button { + router.route(to: .connectToServer) + } label: { + L10n.connect.text + .font(.callout) + .fontWeight(.bold) + .frame(width: 400, height: 75) + .background(Color.jellyfinPurple) + } + .buttonStyle(.card) + } + } + + // MARK: - Functions + + private func didDelete(_ server: ServerState) { + viewModel.getServers() + + if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { + if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { + serverSelection = .server(id: first.id) + } else { + serverSelection = .all + } + } + } + + // MARK: - Body + + var body: some View { + ZStack { + if viewModel.servers.isEmpty { + connectToServerView + } else { + contentView + } + } + .ignoresSafeArea() + .navigationBarBranding() + .onAppear { + viewModel.getServers() + } + .onChange(of: isEditingUsers) { + guard !isEditingUsers else { return } + selectedUsers.removeAll() + } + .onChange(of: isPresentingLocalPin) { + if isPresentingLocalPin { + pin = "" + } else { + selectedUsers.removeAll() + } + } + .onChange(of: viewModel.servers.keys) { + let newValue = viewModel.servers.keys + + if case let SelectUserServerSelection.server(id: id) = serverSelection, + !newValue.contains(where: { $0.id == id }) + { + if newValue.count == 1, let firstServer = newValue.first { + let newSelection = SelectUserServerSelection.server(id: firstServer.id) + serverSelection = newSelection + selectUserAllServersSplashscreen = newSelection + } else { + serverSelection = .all + selectUserAllServersSplashscreen = .all + } + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .signedIn(user): + Defaults[.lastSignedInUserID] = .signedIn(userID: user.id) + Container.shared.currentUserSession.reset() + Notifications[.didSignIn].post() + } + } + .onNotification(.didConnectToServer) { server in + viewModel.getServers() + serverSelection = .server(id: server.id) + } + .onNotification(.didChangeCurrentServerURL) { _ in + viewModel.getServers() + } + .onNotification(.didDeleteServer) { _ in + viewModel.getServers() + } + .confirmationDialog( + Text(L10n.deleteUser), + isPresented: $isPresentingConfirmDeleteUsers + ) { + Button(L10n.delete, role: .destructive) { + viewModel.deleteUsers(selectedUsers) + } + } message: { + if selectedUsers.count == 1, let first = selectedUsers.first { + Text(L10n.deleteUserSingleConfirmation(first.username)) + } else { + Text(L10n.deleteUserMultipleConfirmation(selectedUsers.count)) + } + } + .alert(L10n.signIn, isPresented: $isPresentingLocalPin) { + + // TODO: Verify on tvOS 18 + // https://forums.developer.apple.com/forums/thread/739545 + // TextField(L10n.pin, text: $pin) + TextField(text: $pin) {} + .keyboardType(.numberPad) + + Button(L10n.signIn) { + guard let user = selectedUsers.first else { + assertionFailure("User not selected") + return + } + select(user: user, needsPin: false) + } + + Button(L10n.cancel, role: .cancel) {} + } message: { + if let user = selectedUsers.first, user.pinHint.isNotEmpty { + Text(user.pinHint) + } else { + let username = selectedUsers.first?.username ?? .emptyDash + + Text(L10n.enterPinForUser(username)) + } + } + .errorMessage($viewModel.error) + } +} diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift new file mode 100644 index 00000000..0c487a92 --- /dev/null +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -0,0 +1,109 @@ +// +// 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 + +struct EditServerView: View { + + @Router + private var router + + @Environment(\.isEditing) + private var isEditing + + @State + private var isPresentingConfirmDeletion: Bool = false + + @StateObject + private var viewModel: ServerConnectionViewModel + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "server.rack") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section(L10n.server) { + LabeledContent( + L10n.name, + value: viewModel.server.name + ) + .focusable(false) + + if let serverVerion = StoredValues[.Server.publicInfo(id: viewModel.server.id)].version { + LabeledContent( + L10n.version, + value: serverVerion + ) + .focusable(false) + } + } + + Section { + ListRowMenu(L10n.serverURL, subtitle: viewModel.server.currentURL.absoluteString) { + ForEach(viewModel.server.urls.sorted(using: \.absoluteString), id: \.self) { url in + Button { + guard viewModel.server.currentURL != url else { return } + viewModel.setCurrentURL(to: url) + } label: { + HStack { + Text(url.absoluteString) + .foregroundColor(.primary) + + Spacer() + + if viewModel.server.currentURL == url { + Image(systemName: "checkmark") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + } + } + } + } header: { + L10n.url.text + } footer: { + if !viewModel.server.isVersionCompatible { + Label( + L10n.serverVersionWarning(JellyfinClient.sdkVersion.majorMinor.description), + systemImage: "exclamationmark.circle.fill" + ) + } + } + + if isEditing { + Section { + ListRowButton(L10n.delete, role: .destructive) { + isPresentingConfirmDeletion = true + } + .listRowBackground(Color.clear) + .listRowInsets(.zero) + } + } + } + .navigationTitle(L10n.server) + .alert(L10n.deleteServer, isPresented: $isPresentingConfirmDeletion) { + Button(L10n.delete, role: .destructive) { + viewModel.delete() +// router.popLast() + } + } message: { + Text(L10n.confirmDeleteServerAndUsers(viewModel.server.name)) + } + } +} diff --git a/jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift rename to Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift diff --git a/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift new file mode 100644 index 00000000..1398f30f --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift @@ -0,0 +1,145 @@ +// +// 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 CustomDeviceProfileSettingsView { + + struct EditCustomDeviceProfileView: View { + + @StoredValue(.User.customDeviceProfiles) + private var customDeviceProfiles + + @Router + private var router + + @State + private var isPresentingNotSaved = false + + @StateObject + private var profile: BindingBox + + private let createProfile: Bool + + private var isValid: Bool { + profile.value.audio.isNotEmpty && + profile.value.video.isNotEmpty && + profile.value.container.isNotEmpty + } + + init(profile: Binding?) { + createProfile = profile == nil + + if let profile { + self._profile = StateObject(wrappedValue: BindingBox(source: profile)) + } else { + let empty = Binding( + get: { .init(type: .video) }, + set: { _ in } + ) + + self._profile = StateObject( + wrappedValue: BindingBox(source: empty) + ) + } + } + + @ViewBuilder + private func codecSection( + title: String, + content: String, + onSelect: @escaping () -> Void + ) -> some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .fontWeight(.semibold) + + if content.isEmpty { + Label(L10n.none, systemImage: "exclamationmark.circle.fill") + } else { + Text(content) + .foregroundColor(.secondary) + } + } + .font(.subheadline) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "doc") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + Section { + Toggle(L10n.useAsTranscodingProfile, isOn: $profile.value.useAsTranscodingProfile) + .padding(.vertical) + } header: { + HStack { + Text(L10n.customProfile) + Spacer() + Button(L10n.save) { + if createProfile { + customDeviceProfiles.append(profile.value) + } + router.dismiss() + } + .disabled(!isValid) + } + } + + codecSection( + title: L10n.audio, + content: profile.value.audio.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileAudio(selection: $profile.value.audio)) + } + .padding(.vertical) + + codecSection( + title: L10n.video, + content: profile.value.video.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileVideo(selection: $profile.value.video)) + } + .padding(.vertical) + + codecSection( + title: L10n.containers, + content: profile.value.container.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileContainer(selection: $profile.value.container)) + } + .padding(.vertical) + + if !isValid { + Label(L10n.replaceDeviceProfileWarning, systemImage: "exclamationmark.circle.fill") + } + } + .navigationTitle(L10n.customProfile) + .alert(L10n.profileNotSaved, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { + router.dismiss() + } + } + .interactiveDismissDisabled(true) + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift new file mode 100644 index 00000000..bd1f658d --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift @@ -0,0 +1,102 @@ +// +// 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 SwiftUI + +struct CustomDeviceProfileSettingsView: View { + + @Default(.VideoPlayer.Playback.customDeviceProfileAction) + private var customDeviceProfileAction + + @StoredValue(.User.customDeviceProfiles) + private var customProfiles: [CustomDeviceProfile] + + @Router + private var router + + private var isValid: Bool { + customDeviceProfileAction == .add || customProfiles.isNotEmpty + } + + private func removeProfile(at offsets: IndexSet) { + customProfiles.remove(atOffsets: offsets) + } + + private func deleteProfile(_ profile: CustomDeviceProfile) { + if let index = customProfiles.firstIndex(of: profile) { + customProfiles.remove(at: index) + } + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "doc.on.doc") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + Section { + ListRowMenu(L10n.behavior, selection: $customDeviceProfileAction) + } header: { + L10n.behavior.text + } footer: { + VStack(spacing: 8) { + switch customDeviceProfileAction { + case .add: + L10n.customDeviceProfileAdd.text + case .replace: + L10n.customDeviceProfileReplace.text + } + + if !isValid { + Label(L10n.noDeviceProfileWarning, systemImage: "exclamationmark.circle.fill") + } + } + } + + Section { + if customProfiles.isEmpty { + Button(L10n.add) { + router.route(to: .createCustomDeviceProfile) + } + } + + List { + ForEach($customProfiles, id: \.self) { $profile in + CustomProfileButton(profile: profile) { + router.route(to: .editCustomDeviceProfile(profile: $profile)) + } + .contextMenu { + Button(role: .destructive) { + deleteProfile(profile) + } label: { + Label(L10n.delete, systemImage: "trash") + } + } + } + .onDelete(perform: removeProfile) + } + } header: { + HStack { + Text(L10n.profiles) + Spacer() + if customProfiles.isNotEmpty { + Button(L10n.add) { + router.route(to: .createCustomDeviceProfile) + } + } + } + } + } + .navigationTitle(L10n.profiles) + } +} diff --git a/jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift rename to Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift new file mode 100644 index 00000000..b79ace64 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -0,0 +1,55 @@ +// +// 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 CustomizeViewsSettings { + + struct HomeSection: View { + + @Default(.Customization.Home.showRecentlyAdded) + private var showRecentlyAdded + @Default(.Customization.Home.maxNextUp) + private var maxNextUp + @Default(.Customization.Home.resumeNextUp) + private var resumeNextUp + + @State + private var isPresentingNextUpDays = false + + var body: some View { + Section(L10n.home) { + + Toggle(L10n.showRecentlyAdded, isOn: $showRecentlyAdded) + + Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) + + ChevronButton( + L10n.nextUpDays, + subtitle: { + if maxNextUp > 0 { + let duration = Duration.seconds(TimeInterval(maxNextUp)) + return Text(duration, format: .units(allowed: [.days], width: .abbreviated)) + } else { + return Text(L10n.disabled) + } + }(), + description: L10n.nextUpDaysDescription + ) { + TextField( + L10n.days, + value: $maxNextUp, + format: .dayInterval(range: 0 ... 1000) + ) + .keyboardType(.numberPad) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift new file mode 100644 index 00000000..f3d781ac --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -0,0 +1,59 @@ +// +// 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 SwiftUI + +extension CustomizeViewsSettings { + + struct ItemSection: View { + + @Injected(\.currentUserSession) + private var userSession + + @Router + private var router + + @StoredValue(.User.itemViewAttributes) + private var itemViewAttributes + @StoredValue(.User.enabledTrailers) + private var enabledTrailers + + @StoredValue(.User.enableItemEditing) + private var enableItemEditing + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement + + var body: some View { + Section(L10n.items) { + + ChevronButton(L10n.mediaAttributes) { + router.route(to: .itemViewAttributes(selection: $itemViewAttributes)) + } + + ListRowMenu(L10n.enabledTrailers, selection: $enabledTrailers) + + /// Enable Refreshing & Deleting Collections + if userSession?.user.permissions.items.canManageCollections == true { + Toggle(L10n.editCollections, isOn: $enableCollectionManagement) + } + /// Enable Refreshing Items from All Visible LIbraries + if userSession?.user.permissions.items.canEditMetadata == true { + Toggle(L10n.editMedia, isOn: $enableItemEditing) + } + /// Enable Deleting Items from Approved Libraries + if userSession?.user.permissions.items.canDelete == true { + Toggle(L10n.deleteMedia, isOn: $enableItemDeletion) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift new file mode 100644 index 00000000..c6ab7672 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift @@ -0,0 +1,80 @@ +// +// 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 CustomizeViewsSettings { + + struct LibrarySection: View { + + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + @Default(.Customization.Library.showFavorites) + private var showFavorites + + @Default(.Customization.Library.cinematicBackground) + private var cinematicBackground + @Default(.Customization.Library.displayType) + private var libraryDisplayType + @Default(.Customization.Library.posterType) + private var libraryPosterType + @Default(.Customization.Library.listColumnCount) + private var listColumnCount + + @Default(.Customization.Library.rememberLayout) + private var rememberLibraryLayout + @Default(.Customization.Library.rememberSort) + private var rememberLibrarySort + + @Router + private var router + + @State + private var isPresentingNextUpDays = false + + var body: some View { + Section(L10n.media) { + + Toggle(L10n.randomImage, isOn: $libraryRandomImage) + + Toggle(L10n.showFavorites, isOn: $showFavorites) + } + + Section(L10n.library) { + Toggle(L10n.cinematicBackground, isOn: $cinematicBackground) + + ListRowMenu(L10n.posters, selection: $libraryPosterType) + + ListRowMenu(L10n.library, selection: $libraryDisplayType) + + if libraryDisplayType == .list { + ChevronButton( + L10n.columns, + subtitle: listColumnCount.description + ) { + // TODO: Implement listColumnSettings route in new Router system +// router.route(to: .listColumnSettings(columnCount: $listColumnCount)) + } + } + } + + Section { + Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) + } footer: { + Text(L10n.rememberLayoutFooter) + } + + Section { + Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) + } footer: { + Text(L10n.rememberSortingFooter) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift new file mode 100644 index 00000000..e89558ea --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -0,0 +1,81 @@ +// +// 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 + +struct CustomizeViewsSettings: View { + + @Default(.Customization.shouldShowMissingSeasons) + private var shouldShowMissingSeasons + @Default(.Customization.shouldShowMissingEpisodes) + private var shouldShowMissingEpisodes + + @Default(.Customization.showPosterLabels) + private var showPosterLabels + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + @Default(.Customization.similarPosterType) + private var similarPosterType + @Default(.Customization.searchPosterType) + private var searchPosterType + @Default(.Customization.Library.displayType) + private var libraryViewType + + @Router + private var router + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section(L10n.missingItems) { + + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } + + Section(L10n.posters) { + + ChevronButton(L10n.indicators) { + router.route(to: .indicatorSettings) + } + + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + + ListRowMenu(L10n.next, selection: $nextUpPosterType) + + ListRowMenu(L10n.recentlyAdded, selection: $recentlyAddedPosterType) + + ListRowMenu(L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) + + ListRowMenu(L10n.recommended, selection: $similarPosterType) + + ListRowMenu(L10n.search, selection: $searchPosterType) + } + + LibrarySection() + + ItemSection() + + HomeSection() + } + .navigationTitle(L10n.customize) + } +} diff --git a/jellypig tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/ExperimentalSettingsView.swift rename to Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift diff --git a/jellypig tvOS/Views/SettingsView/IndicatorSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/IndicatorSettingsView.swift rename to Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift diff --git a/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift b/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift new file mode 100644 index 00000000..bc8f6c19 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift @@ -0,0 +1,132 @@ +// +// 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 + +struct PlaybackQualitySettingsView: View { + @Default(.VideoPlayer.Playback.appMaximumBitrate) + private var appMaximumBitrate + @Default(.VideoPlayer.Playback.appMaximumBitrateTest) + private var appMaximumBitrateTest + @Default(.VideoPlayer.Playback.compatibilityMode) + private var compatibilityMode + + @Router + private var router + + // MARK: - Focus Management + + @FocusState + private var focusedItem: FocusableItem? + + private enum FocusableItem: Hashable { + case maximumBitrate + case compatibility + } + + // MARK: - Body + + var body: some View { + SplitFormWindowView() + .descriptionView { + descriptionView + } + .contentView { + Section { + ListRowMenu(L10n.maximumBitrate, selection: $appMaximumBitrate) + .focused($focusedItem, equals: .maximumBitrate) + } header: { + L10n.bitrateDefault.text + } footer: { + L10n.bitrateDefaultDescription.text + } + .animation(.none, value: appMaximumBitrate) + + if appMaximumBitrate == .auto { + Section { + ListRowMenu(L10n.testSize, selection: $appMaximumBitrateTest) + } footer: { + L10n.bitrateTestDisclaimer.text + } + } + + Section { + ListRowMenu(L10n.compatibility, selection: $compatibilityMode) + .focused($focusedItem, equals: .compatibility) + + if compatibilityMode == .custom { + ChevronButton(L10n.profiles) { + router.route(to: .customDeviceProfileSettings) + } + } + } header: { + L10n.deviceProfile.text + } footer: { + L10n.deviceProfileDescription.text + } + } + .navigationTitle(L10n.playbackQuality) + } + + // MARK: - Description View Icon + + private var descriptionView: some View { + ZStack { + Image(systemName: "play.rectangle.on.rectangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + + focusedDescription + .transition(.opacity.animation(.linear(duration: 0.2))) + } + } + + // MARK: - Description View on Focus + + @ViewBuilder + private var focusedDescription: some View { + switch focusedItem { + case .maximumBitrate: + LearnMoreModal { + LabeledContent( + L10n.auto, + value: L10n.birateAutoDescription + ) + LabeledContent( + L10n.bitrateMax, + value: L10n.bitrateMaxDescription(PlaybackBitrate.max.rawValue.formatted(.bitRate)) + ) + } + + case .compatibility: + LearnMoreModal { + LabeledContent( + L10n.auto, + value: L10n.autoDescription + ) + LabeledContent( + L10n.compatible, + value: L10n.compatibleDescription + ) + LabeledContent( + L10n.direct, + value: L10n.directDescription + ) + LabeledContent( + L10n.custom, + value: L10n.customDescription + ) + } + + case nil: + EmptyView() + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift new file mode 100644 index 00000000..e52714c0 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -0,0 +1,100 @@ +// +// 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 + +struct SettingsView: View { + + @Default(.VideoPlayer.videoPlayerType) + private var videoPlayerType + + @Router + private var router + + @StateObject + private var viewModel = SettingsViewModel() + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + Section(L10n.jellyfin) { + + UserProfileRow(user: viewModel.userSession.user.data) { + router.route(to: .userProfile(viewModel: viewModel)) + } + + ChevronButton( + L10n.server, + action: { + router.route(to: .editServer(server: viewModel.userSession.server)) + }, + icon: { EmptyView() }, + subtitle: { + Label { + Text(viewModel.userSession.server.name) + } icon: { + if !viewModel.userSession.server.isVersionCompatible { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + } + } + } + ) + } + + Section { + ListRowButton(L10n.switchUser) { + viewModel.signOut() + } + .foregroundStyle(Color.jellyfinPurple.overlayColor, Color.jellyfinPurple) + .listRowInsets(.zero) + } + + Section(L10n.videoPlayer) { + + ListRowMenu(L10n.videoPlayerType, selection: $videoPlayerType) + + ChevronButton(L10n.videoPlayer) { + router.route(to: .videoPlayerSettings) + } + + ChevronButton(L10n.playbackQuality) { + router.route(to: .playbackQualitySettings) + } + } + + Section(L10n.accessibility) { + + ChevronButton(L10n.customize) { + router.route(to: .customizeViewsSettings) + } +// +// ChevronButton(L10n.experimental) +// .onSelect { +// router.route(to: \.experimentalSettings) +// } + } + + Section { + + ChevronButton(L10n.logs) { + router.route(to: .log) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift b/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift new file mode 100644 index 00000000..818e3a39 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift @@ -0,0 +1,278 @@ +// +// 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 Combine +import Defaults +import KeychainSwift +import SwiftUI + +// TODO: present toast when authentication successfully changed +// TODO: pop is just a workaround to get change published from usersession. +// find fix and don't pop when successfully changed +// TODO: could cleanup/refactor greatly + +struct UserLocalSecurityView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - State & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel = UserLocalSecurityViewModel() + + // MARK: - Local Security Variables + + @State + private var listSize: CGSize = .zero + @State + private var onPinCompletion: (() -> Void)? + @State + private var pin: String = "" + @State + private var pinHint: String = "" + @State + private var signInPolicy: UserAccessPolicy = .none + + // MARK: - Dialog States + + @State + private var isPresentingOldPinPrompt: Bool = false + @State + private var isPresentingNewPinPrompt: Bool = false + + // MARK: - Error State + + @State + private var error: Error? = nil + + // MARK: - Focus Management + + @FocusState + private var focusedItem: FocusableItem? + + private enum FocusableItem: Hashable { + case security + } + + // MARK: - Check Old Policy + + private func checkOldPolicy() { + do { + try viewModel.checkForOldPolicy() + } catch { + return + } + + checkNewPolicy() + } + + // MARK: - Check New Policy + + private func checkNewPolicy() { + do { + try viewModel.checkFor(newPolicy: signInPolicy) + } catch { + return + } + + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + } + + // MARK: - Event Handler + + private func onReceive(_ event: UserLocalSecurityViewModel.Event) { + switch event { + case let .error(eventError): + error = eventError + case .promptForOldPin: + onPinCompletion = { + Task { + try viewModel.check(oldPin: pin) + + checkNewPolicy() + } + } + + pin = "" + isPresentingOldPinPrompt = true + case .promptForNewPin: + onPinCompletion = { + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + router.dismiss() + } + + pin = "" + isPresentingNewPinPrompt = true + case .promptForOldDeviceAuth, .promptForNewDeviceAuth: + break + } + } + + // MARK: - Body + + var body: some View { + SplitFormWindowView() + .descriptionView { + descriptionView + } + .contentView { + Section { + Toggle( + L10n.pin, + isOn: Binding( + get: { signInPolicy == .requirePin }, + set: { signInPolicy = $0 ? .requirePin : .none } + ) + ) + .focused($focusedItem, equals: .security) + /* Picker(L10n.security, selection: $signInPolicy) { + ForEach(UserAccessPolicy.allCases.filter { $0 != .requireDeviceAuthentication }, id: \.self) { policy in + Text(policy.displayTitle) + } + } */ + } + + if signInPolicy == .requirePin { + Section { + ChevronButton( + L10n.hint, + subtitle: pinHint, + description: L10n.setPinHintDescription + ) { + // TODO: Verify on tvOS 18 + // https://forums.developer.apple.com/forums/thread/739545 + // TextField(L10n.hint, text: $pinHint) + TextField(text: $pinHint) {} + } + } header: { + Text(L10n.hint) + } footer: { + Text(L10n.setPinHintDescription) + } + } + } + .animation(.linear, value: signInPolicy) + .navigationTitle(L10n.security) + .onFirstAppear { + pinHint = viewModel.userSession.user.pinHint + signInPolicy = viewModel.userSession.user.accessPolicy + } + .onReceive(viewModel.events) { event in + onReceive(event) + } + .topBarTrailing { + Button { + checkOldPolicy() + } label: { + Group { + if signInPolicy == .requirePin, signInPolicy == viewModel.userSession.user.accessPolicy { + Text(L10n.changePin) + } else { + Text(L10n.save) + } + } + .foregroundStyle(accentColor.overlayColor) + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + accentColor + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .trackingSize($listSize) + .alert( + L10n.enterPin, + isPresented: $isPresentingOldPinPrompt, + presenting: onPinCompletion + ) { completion in + + // TODO: Verify on tvOS 18 + // https://forums.developer.apple.com/forums/thread/739545 + // SecureField(L10n.pin, text: $pin) + SecureField(text: $pin) {} + .keyboardType(.numberPad) + + Button(L10n.continue) { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text(L10n.enterPinForUser(viewModel.userSession.user.username)) + } + .alert( + L10n.setPin, + isPresented: $isPresentingNewPinPrompt, + presenting: onPinCompletion + ) { completion in + + // TODO: Verify on tvOS 18 + // https://forums.developer.apple.com/forums/thread/739545 + // SecureField(L10n.pin, text: $pin) + SecureField(text: $pin) {} + .keyboardType(.numberPad) + + Button(L10n.set) { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text(L10n.createPinForUser(viewModel.userSession.user.username)) + } + .errorMessage($error) + } + + // MARK: - Description View Icon + + private var descriptionView: some View { + ZStack { + Image(systemName: "lock.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + + focusedDescription + .transition(.opacity.animation(.linear(duration: 0.2))) + } + } + + // MARK: - Description View on Focus + + @ViewBuilder + private var focusedDescription: some View { + switch focusedItem { + case .security: + LearnMoreModal { + LabeledContent( + L10n.security, + value: L10n.additionalSecurityAccessDescription + ) + LabeledContent( + UserAccessPolicy.requirePin.displayTitle, + value: L10n.requirePinDescription + ) + LabeledContent( + UserAccessPolicy.none.displayTitle, + value: L10n.saveUserWithoutAuthDescription + ) + } + + case nil: + EmptyView() + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift new file mode 100644 index 00000000..109b3689 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -0,0 +1,90 @@ +// +// 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 + +struct UserProfileSettingsView: View { + + @Router + private var router + + @ObservedObject + private var viewModel: SettingsViewModel + @StateObject + private var profileImageViewModel: UserProfileImageViewModel + + @State + private var isPresentingConfirmReset: Bool = false + + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data)) + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + UserProfileImage( + userID: viewModel.userSession.user.id, + source: viewModel.userSession.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 400 + ) + ) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + // TODO: bring reset password to tvOS +// Section { +// ChevronButton(L10n.password) +// .onSelect { +// router.route(to: \.resetUserPassword, viewModel.userSession.user.id) +// } +// } + + Section { + ChevronButton(L10n.security) { + router.route(to: .localSecurity) + } + } + + // TODO: Do we want this option on tvOS? +// Section { +// // TODO: move under future "Storage" tab +// // when downloads implemented +// Button(L10n.resetSettings) { +// isPresentingConfirmReset = true +// } +// .foregroundStyle(.red) +// } footer: { +// Text(L10n.resetSettingsDescription) +// } + } + .navigationTitle(L10n.user) + .confirmationDialog( + L10n.resetSettings, + isPresented: $isPresentingConfirmReset, + titleVisibility: .visible + ) { + Button(L10n.reset, role: .destructive) { + do { + try viewModel.userSession.user.deleteSettings() + } catch { + viewModel.logger.error("Unable to reset user settings: \(error.localizedDescription)") + } + } + } message: { + Text(L10n.resetSettingsMessage) + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift new file mode 100644 index 00000000..e6476aca --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift @@ -0,0 +1,82 @@ +// +// 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 + +struct VideoPlayerSettingsView: View { + + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + + @Default(.VideoPlayer.jumpBackwardInterval) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardInterval) + private var jumpForwardLength + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + @Router + private var router + + @State + private var isPresentingResumeOffsetStepper: Bool = false + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section { + + ChevronButton( + L10n.offset, + subtitle: resumeOffset.secondLabel + ) { + isPresentingResumeOffsetStepper = true + } + } header: { + L10n.resume.text + } footer: { + L10n.resumeOffsetDescription.text + } + + Section { + + ChevronButton(L10n.subtitleFont, subtitle: subtitleFontName) { + router.route(to: .fontPicker(selection: $subtitleFontName)) + } + } header: { + L10n.subtitles.text + } footer: { + L10n.subtitlesDisclaimer.text + } + } + .navigationTitle(L10n.videoPlayer) + .blurredFullScreenCover(isPresented: $isPresentingResumeOffsetStepper) { + StepperView( + title: L10n.resumeOffsetTitle, + description: L10n.resumeOffsetDescription, + value: $resumeOffset, + range: 0 ... 30, + step: 1 + ) + .valueFormatter { + $0.secondLabel + } + .onCloseSelected { + isPresentingResumeOffsetStepper = false + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/Components/LoadingView.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/Components/LoadingView.swift new file mode 100644 index 00000000..4f5de256 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/Components/LoadingView.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer { + + struct LoadingView: View { + + @Router + private var router + + var body: some View { + ZStack { + Color.black + + VStack(spacing: 10) { + + Text(L10n.retrievingMediaInformation) + .foregroundColor(.white) + + ProgressView() + + Button { + router.dismiss() + } label: { + Text(L10n.cancel) + .foregroundColor(.red) + .padding() + .overlay { + Capsule() + .stroke(Color.red, lineWidth: 1) + } + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift new file mode 100644 index 00000000..4e05df6b --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift @@ -0,0 +1,126 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension VideoPlayer.PlaybackControls.NavigationBar { + + struct ActionButtons: View { + + @Default(.VideoPlayer.barActionButtons) + private var rawBarActionButtons + @Default(.VideoPlayer.menuActionButtons) + private var rawMenuActionButtons + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + private func filteredActionButtons(_ rawButtons: [VideoPlayerActionButton]) -> [VideoPlayerActionButton] { + var filteredButtons = rawButtons + + if manager.playbackItem?.audioStreams.isEmpty == true { + filteredButtons.removeAll { $0 == .audio } + } + + if manager.playbackItem?.subtitleStreams.isEmpty == true { + filteredButtons.removeAll { $0 == .subtitles } + } + + if manager.queue == nil { + filteredButtons.removeAll { $0 == .autoPlay } + filteredButtons.removeAll { $0 == .playNextItem } + filteredButtons.removeAll { $0 == .playPreviousItem } + } + + if manager.item.isLiveStream { + filteredButtons.removeAll { $0 == .audio } + filteredButtons.removeAll { $0 == .autoPlay } + filteredButtons.removeAll { $0 == .playbackSpeed } +// filteredButtons.removeAll { $0 == .playbackQuality } + filteredButtons.removeAll { $0 == .subtitles } + } + + return filteredButtons + } + + private var barActionButtons: [VideoPlayerActionButton] { + filteredActionButtons(rawBarActionButtons) + } + + private var menuActionButtons: [VideoPlayerActionButton] { + filteredActionButtons(rawMenuActionButtons) + } + + @ViewBuilder + private func view(for button: VideoPlayerActionButton) -> some View { + switch button { + case .aspectFill: + AspectFill() + case .audio: + Audio() + case .autoPlay: + AutoPlay() + case .gestureLock: + EmptyView() +// GestureLock() + case .playbackSpeed: + EmptyView() +// PlaybackRateMenu() +// case .playbackQuality: +// PlaybackQuality() + case .playNextItem: + PlayNextItem() + case .playPreviousItem: + PlayPreviousItem() + case .subtitles: + Subtitles() + } + } + + @ViewBuilder + private var menuButtons: some View { + Menu( + "Menu", + systemImage: "ellipsis.circle" + ) { + ForEach(menuActionButtons) { actionButton in + view(for: actionButton) + } + } + } + + var body: some View { + HStack(spacing: 10) { + ForEach( + barActionButtons, + content: view(for:) + ) + + if menuActionButtons.isNotEmpty { + Menu( + L10n.menu, + systemImage: "ellipsis.circle" + ) { + ForEach( + menuActionButtons, + content: view(for:) + ) + .environment(\.isInMenu, true) + } + } + } + .menuStyle(.button) + .labelStyle(.iconOnly) + .buttonBorderShape(.circle) + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AspectFillActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AspectFillActionButton.swift new file mode 100644 index 00000000..cd27fd31 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AspectFillActionButton.swift @@ -0,0 +1,42 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct AspectFill: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + private var isAspectFilled: Bool { + get { containerState.isAspectFilled } + nonmutating set { containerState.isAspectFilled = newValue } + } + + private var systemImage: String { + if isAspectFilled { + VideoPlayerActionButton.aspectFill.secondarySystemImage + } else { + VideoPlayerActionButton.aspectFill.systemImage + } + } + + var body: some View { + Button( + L10n.aspectFill, + systemImage: systemImage + ) { + isAspectFilled.toggle() + } +// .videoPlayerActionButtonTransition() +// .id(isAspectFilled) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift new file mode 100644 index 00000000..9ec6d591 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift @@ -0,0 +1,66 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct Audio: View { + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var selectedAudioStreamIndex: Int? + + private var systemImage: String { + if selectedAudioStreamIndex == nil { + "speaker.wave.2" + } else { + "speaker.wave.2.fill" + } + } + + @ViewBuilder + private func content(playbackItem: MediaPlayerItem) -> some View { + ForEach(playbackItem.audioStreams, id: \.index) { stream in + Button { + playbackItem.selectedAudioStreamIndex = stream.index ?? -1 + } label: { + if selectedAudioStreamIndex == stream.index { + Label(stream.displayTitle ?? L10n.unknown, systemImage: "checkmark") + } else { + Text(stream.displayTitle ?? L10n.unknown) + } + } + } + } + + var body: some View { + if let playbackItem = manager.playbackItem { + Menu( + L10n.audio, + systemImage: systemImage + ) { + if isInMenu { + content(playbackItem: playbackItem) + } else { + Section(L10n.audio) { + content(playbackItem: playbackItem) + } + } + } + .videoPlayerActionButtonTransition() + .assign(playbackItem.$selectedAudioStreamIndex, to: $selectedAudioStreamIndex) + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift new file mode 100644 index 00000000..4d03fcc2 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift @@ -0,0 +1,57 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct AutoPlay: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var isAutoPlayEnabled + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + private var systemImage: String { + if isAutoPlayEnabled { + "play.circle.fill" + } else { + "stop.circle" + } + } + + var body: some View { + Button { + isAutoPlayEnabled.toggle() + +// if isAutoPlayEnabled { +// toastProxy.present("Auto Play enabled", systemName: "play.circle.fill") +// } else { +// toastProxy.present("Auto Play disabled", systemName: "stop.circle") +// } + } label: { + Label( + L10n.autoPlay, + systemImage: systemImage + ) + + if isInMenu { + Text(isAutoPlayEnabled ? "On" : "Off") + } + } + .videoPlayerActionButtonTransition() + .id(isAutoPlayEnabled) + .disabled(manager.queue == nil) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayNextItemActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayNextItemActionButton.swift new file mode 100644 index 00000000..75414ae1 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayNextItemActionButton.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlayNextItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + if let queue = manager.queue { + _PlayNextItem(queue: queue) + } + } + } + + private struct _PlayNextItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + var queue: AnyMediaPlayerQueue + + var body: some View { + Button( + L10n.playNextItem, + systemImage: VideoPlayerActionButton.playNextItem.systemImage + ) { + guard let nextItem = queue.nextItem else { return } + manager.playNewItem(provider: nextItem) + } + .disabled(queue.nextItem == nil) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayPreviousItemActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayPreviousItemActionButton.swift new file mode 100644 index 00000000..df179f88 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/PlayPreviousItemActionButton.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlayPreviousItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + if let queue = manager.queue { + _PlayPreviousItem(queue: queue) + } + } + } + + private struct _PlayPreviousItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + var queue: AnyMediaPlayerQueue + + var body: some View { + Button( + L10n.playPreviousItem, + systemImage: VideoPlayerActionButton.playPreviousItem.systemImage + ) { + guard let previousItem = queue.previousItem else { return } + manager.playNewItem(provider: previousItem) + } + .disabled(queue.previousItem == nil) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift new file mode 100644 index 00000000..d75c1a0d --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift @@ -0,0 +1,66 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct Subtitles: View { + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var selectedSubtitleStreamIndex: Int? + + private var systemImage: String { + if selectedSubtitleStreamIndex == nil { + "captions.bubble" + } else { + "captions.bubble.fill" + } + } + + @ViewBuilder + private func content(playbackItem: MediaPlayerItem) -> some View { + ForEach(playbackItem.subtitleStreams.prepending(.none), id: \.index) { stream in + Button { + playbackItem.selectedSubtitleStreamIndex = stream.index ?? -1 + } label: { + if selectedSubtitleStreamIndex == stream.index { + Label(stream.displayTitle ?? L10n.unknown, systemImage: "checkmark") + } else { + Text(stream.displayTitle ?? L10n.unknown) + } + } + } + } + + var body: some View { + if let playbackItem = manager.playbackItem { + Menu( + L10n.subtitles, + systemImage: systemImage + ) { + if isInMenu { + content(playbackItem: playbackItem) + } else { + Section(L10n.subtitles) { + content(playbackItem: playbackItem) + } + } + } + .videoPlayerActionButtonTransition() + .assign(playbackItem.$selectedSubtitleStreamIndex, to: $selectedSubtitleStreamIndex) + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/NavigationBar.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/NavigationBar.swift new file mode 100644 index 00000000..a5ca3763 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/NavigationBar.swift @@ -0,0 +1,37 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct NavigationBar: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let subtitle = manager.item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.white) + } + + HStack { + Text(manager.item.displayTitle) + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + ActionButtons() + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/PlaybackProgress.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/PlaybackProgress.swift new file mode 100644 index 00000000..ca3bb582 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/PlaybackProgress.swift @@ -0,0 +1,116 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: bar color default to style +// TODO: remove compact buttons? +// TODO: capsule scale on editing +// TODO: possible issue with runTimeSeconds == 0 +// TODO: live tv + +extension VideoPlayer.PlaybackControls { + + struct PlaybackProgress: View { + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var scrubbedSecondsBox: PublishedBox + + @FocusState + private var isFocused: Bool + + @State + private var sliderSize = CGSize.zero + + private var isScrubbing: Bool { + get { + containerState.isScrubbing + } + nonmutating set { + containerState.isScrubbing = newValue + } + } + + private var previewXOffset: CGFloat { + let p = sliderSize.width * scrubbedProgress + return clamp(p, min: 100, max: sliderSize.width - 100) + } + + private var scrubbedProgress: Double { + guard let runtime = manager.item.runtime, runtime > .zero else { return 0 } + return scrubbedSeconds / runtime + } + + private var scrubbedSeconds: Duration { + scrubbedSecondsBox.value + } + + @ViewBuilder + private var liveIndicator: some View { + Text("Live") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 4) + .background { + Capsule() + .fill(Color.gray) + } + } + + @ViewBuilder + private var capsuleSlider: some View { + + let resolution: Double = 100 + + CapsuleSlider( + value: $scrubbedSecondsBox.value.map( + getter: { + guard let runtime = manager.item.runtime, runtime > .zero else { return 0 } + return clamp(($0.seconds / runtime.seconds) * resolution, min: 0, max: resolution) + }, + setter: { (manager.item.runtime ?? .zero) * ($0 / resolution) } + ), + total: resolution + ) + .onEditingChanged { isEditing in + isScrubbing = isEditing + print(isEditing) + } + .frame(height: 50) + } + + var body: some View { + VStack(spacing: 10) { + if manager.item.isLiveStream { + liveIndicator + .frame(maxWidth: .infinity, alignment: .leading) + } else { + capsuleSlider + .trackingSize($sliderSize) + + SplitTimeStamp() + } + } + .focused($isFocused) + .scaleEffect(isFocused ? 1.0 : 0.95) + .animation(.easeInOut(duration: 0.3), value: isFocused) + .foregroundStyle(isFocused ? Color.white : Color.white.opacity(0.8)) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/SplitTimestamp.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/SplitTimestamp.swift new file mode 100644 index 00000000..68b67168 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/SplitTimestamp.swift @@ -0,0 +1,63 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct SplitTimeStamp: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var scrubbedSecondsBox: PublishedBox + + @State + private var contentSize: CGSize = .zero + @State + private var leadingTimestampSize: CGSize = .zero + @State + private var trailingTimestampSize: CGSize = .zero + + private var previewXOffset: CGFloat { + let p = contentSize.width * scrubbedProgress - (leadingTimestampSize.width / 2) + return clamp(p, min: 0, max: contentSize.width - (trailingTimestampSize.width + leadingTimestampSize.width)) + } + + private var scrubbedProgress: Double { + guard let runtime = manager.item.runtime, runtime > .zero else { return 0 } + return scrubbedSeconds / runtime + } + + private var scrubbedSeconds: Duration { + scrubbedSecondsBox.value + } + + var body: some View { + ZStack { + if let runtime = manager.item.runtime { + Text(.zero - (runtime - scrubbedSeconds), format: .runtime) + } else { + Text(verbatim: .emptyRuntime) + } + } + .trackingSize($trailingTimestampSize) + .frame(maxWidth: .infinity, alignment: .trailing) + .debugBackground() + .overlay(alignment: .leading) { + Text(scrubbedSeconds, format: .runtime) + .trackingSize($leadingTimestampSize) + .offset(x: previewXOffset) + } + .monospacedDigit() + .trackingSize($contentSize) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/ConfirmCloseOverlay.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/ConfirmCloseOverlay.swift new file mode 100644 index 00000000..803f58c2 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/ConfirmCloseOverlay.swift @@ -0,0 +1,28 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct ConfirmCloseOverlay: View { + + var body: some View { + ZStack { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 96)) + .padding(3) + .background { + Circle() + .fill(Color.black.opacity(0.4)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/PlaybackControls.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/PlaybackControls.swift new file mode 100644 index 00000000..3ad80086 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/PlaybackControls.swift @@ -0,0 +1,101 @@ +// +// 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 PreferencesView +import SwiftUI +import VLCUI + +extension VideoPlayer { + + struct PlaybackControls: View { + + // since this view ignores safe area, it must + // get safe area insets from parent views + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var focusGuide: FocusGuide + @EnvironmentObject + private var manager: MediaPlayerManager + + @OnPressEvent + private var onPressEvent + + @Router + private var router + + @State + private var contentSize: CGSize = .zero + @State + private var effectiveSafeArea: EdgeInsets = .zero + + private var isPresentingOverlay: Bool { + containerState.isPresentingOverlay + } + + private var isPresentingSupplement: Bool { + containerState.isPresentingSupplement + } + + private var isScrubbing: Bool { + containerState.isScrubbing + } + + @ViewBuilder + private var bottomContent: some View { + if !isPresentingSupplement { + + NavigationBar() + .focusSection() + + PlaybackProgress() + .focusGuide(focusGuide, tag: "playbackProgress") +// .isVisible(isScrubbing || isPresentingOverlay) + } + } + + var body: some View { + VStack { + Spacer() + + bottomContent + .edgePadding() + .background(alignment: .bottom) { + Color.black + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 1, opacity: 0.5) + } + .isVisible(isScrubbing || isPresentingOverlay) + .animation(.linear(duration: 0.25), value: isPresentingOverlay) + } + } + .animation(.linear(duration: 0.1), value: isScrubbing) + .animation(.bouncy(duration: 0.4), value: isPresentingSupplement) + .animation(.bouncy(duration: 0.25), value: isPresentingOverlay) + .onReceive(onPressEvent) { press in + switch press { + case (.playPause, _): + manager.togglePlayPause() + case (.menu, _): + if isPresentingSupplement { + containerState.selectedSupplement = nil + } else { + manager.proxy?.stop() + router.dismiss() + } + default: () + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/SupplementContainerView.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/SupplementContainerView.swift new file mode 100644 index 00000000..2302ad08 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/SupplementContainerView.swift @@ -0,0 +1,144 @@ +// +// 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 IdentifiedCollections +import SwiftUI + +struct SupplementContainerView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var focusGuide: FocusGuide + + @EnvironmentObject + private var manager: MediaPlayerManager + + @FocusState + private var focusedSupplementID: AnyMediaPlayerSupplement.ID? + @FocusState + private var isFocused: Bool + @FocusState + private var isTopBoundaryFocused: Bool + + @State + private var currentSupplements: IdentifiedArrayOf = [] + + @ViewBuilder + private func supplementContainer(for supplement: some MediaPlayerSupplement) -> some View { + AlternateLayoutView(alignment: .topLeading) { + Color.clear + } content: { + supplement.videoPlayerBody + } + } + + var body: some View { + VStack(spacing: EdgeInsets.edgePadding) { + + HStack(spacing: 10) { + if containerState.isGuestSupplement, let supplement = containerState.selectedSupplement { + Button(supplement.displayTitle) { + containerState.select(supplement: nil) + } + .focused($focusedSupplementID, equals: supplement.id) + } else { + ForEach(currentSupplements) { supplement in + Button(supplement.displayTitle) {} + .focused($focusedSupplementID, equals: supplement.id) + } + } + } + .buttonStyle(SupplementTitleButtonStyle()) + .padding(.leading, EdgeInsets.edgePadding) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 75) + .focusGuide(focusGuide, tag: "supplementTitles", top: "playbackControls") + + ZStack { + if let supplement = containerState.selectedSupplement { + supplementContainer(for: supplement) + .eraseToAnyView() + } + } + } + .isVisible(containerState.isPresentingOverlay) + .animation(.linear(duration: 0.2), value: containerState.isPresentingOverlay) + .background(Color.blue.opacity(0.2)) + .focusSection() + .focused($isFocused) + .onReceive(manager.$supplements) { newValue in + let newSupplements = IdentifiedArray( + uniqueElements: newValue.map(AnyMediaPlayerSupplement.init) + ) + currentSupplements = newSupplements + } + .onReceive(containerState.$selectedSupplement) { output in + if focusedSupplementID != output?.id { + focusedSupplementID = output?.id + } + } + .onChange(of: focusedSupplementID) { _, _ in + if focusedSupplementID != containerState.selectedSupplement?.id {} + } + .onChange(of: isTopBoundaryFocused) { _, _ in + containerState.selectedSupplement = nil + } + } +} + +struct SupplementTitleButtonStyle: PrimitiveButtonStyle { + + @FocusState + private var isFocused + + @State + private var isPressed: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isFocused ? .black : .white) + .padding(10) + .padding(.horizontal, 10) + .background { + if isFocused { + Rectangle() + .foregroundStyle(.white) + } + } + .overlay { + if !isFocused { + RoundedRectangle(cornerRadius: 27) + .stroke(Color.white, lineWidth: 4) + } + } + .mask { + RoundedRectangle(cornerRadius: 27) + } +// .onLongPressGesture(minimumDuration: 0.01) {} onPressingChanged: { isPressing in +// isPressed = isPressing +// } + .scaleEffect( + x: isFocused ? 1.1 : 1, + y: isFocused ? 1.1 : 1, + anchor: .init(x: 0.5, y: 0.5) + ) + .animation(.bouncy(duration: 0.4), value: isFocused) + .opacity(isPressed ? 0.6 : 1) + .animation(.linear(duration: 0.05), value: isFocused) + .focusable() + .focused($isFocused) + .onChange(of: isFocused) { _, newValue in + if newValue { + configuration.trigger() + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift new file mode 100644 index 00000000..3f9f4d49 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift @@ -0,0 +1,277 @@ +// +// 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 Combine +import Engine +import SwiftUI + +extension VideoPlayer { + struct VideoPlayerContainerView: UIViewControllerRepresentable { + + private let containerState: VideoPlayerContainerState + private let manager: MediaPlayerManager + private let player: () -> Player + private let playbackControls: () -> PlaybackControls + + init( + containerState: VideoPlayerContainerState, + manager: MediaPlayerManager, + @ViewBuilder player: @escaping () -> Player, + @ViewBuilder playbackControls: @escaping () -> PlaybackControls + ) { + self.containerState = containerState + self.manager = manager + self.player = player + self.playbackControls = playbackControls + } + + func makeUIViewController(context: Context) -> UIVideoPlayerContainerViewController { + UIVideoPlayerContainerViewController( + containerState: containerState, + manager: manager, + player: player().eraseToAnyView(), + playbackControls: playbackControls().eraseToAnyView() + ) + } + + func updateUIViewController( + _ uiViewController: UIVideoPlayerContainerViewController, + context: Context + ) {} + } + + // MARK: - UIVideoPlayerContainerViewController + + class UIVideoPlayerContainerViewController: UIViewController { + + private struct PlayerContainerView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + let player: AnyView + + var body: some View { + player + .overlay(Color.black.opacity(containerState.isPresentingPlaybackControls ? 0.3 : 0.0)) + .animation(.linear(duration: 0.2), value: containerState.isPresentingPlaybackControls) + } + } + + private lazy var playerViewController: HostingController = { + let controller = HostingController( + content: PlayerContainerView(player: player) + .environmentObject(containerState) + .environmentObject(manager) + .eraseToAnyView() + ) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private lazy var playbackControlsViewController: HostingController = { + let controller = HostingController( + content: playbackControls + .environment(\.onPressEventPublisher, onPressEvent) + .environmentObject(containerState) + .environmentObject(containerState.scrubbedSeconds) + .environmentObject(focusGuide) + .environmentObject(manager) + .eraseToAnyView() + ) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private lazy var supplementContainerViewController: HostingController = { + let content = SupplementContainerView() + .environmentObject(containerState) + .environmentObject(focusGuide) + .environmentObject(manager) + .eraseToAnyView() + let controller = HostingController(content: content) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private var playerView: UIView { playerViewController.view } + private var playbackControlsView: UIView { playbackControlsViewController.view } + private var supplementContainerView: UIView { supplementContainerViewController.view } + + private var supplementRegularConstraints: [NSLayoutConstraint] = [] + private var playerRegularConstraints: [NSLayoutConstraint] = [] + private var playbackControlsConstraints: [NSLayoutConstraint] = [] + + private var supplementHeightAnchor: NSLayoutConstraint! + private var supplementBottomAnchor: NSLayoutConstraint! + + private let manager: MediaPlayerManager + private let player: AnyView + private let playbackControls: AnyView + private let containerState: VideoPlayerContainerState + + let focusGuide = FocusGuide() + let onPressEvent = OnPressEvent() + + private var cancellables: Set = [] + + init( + containerState: VideoPlayerContainerState, + manager: MediaPlayerManager, + player: AnyView, + playbackControls: AnyView + ) { + self.containerState = containerState + self.manager = manager + self.player = player + self.playbackControls = playbackControls + + super.init(nibName: nil, bundle: nil) + + containerState.containerView = self + containerState.manager = manager + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - didPresent + + func presentSupplementContainer( + _ didPresent: Bool + ) { + if didPresent { + self.supplementBottomAnchor.constant = -(500 + EdgeInsets.edgePadding * 2) + } else { + self.supplementBottomAnchor.constant = -(100 + EdgeInsets.edgePadding) + } + + containerState.isPresentingPlaybackControls = !didPresent + containerState.supplementOffset = supplementBottomAnchor.constant + + UIView.animate( + withDuration: 0.75, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.4, + options: .allowUserInteraction + ) { + self.view.layoutIfNeeded() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + setupViews() + setupConstraints() + + let gesture = UITapGestureRecognizer(target: self, action: #selector(ignorePress)) + gesture.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] + view.addGestureRecognizer(gesture) + } + + private func setupViews() { + addChild(playerViewController) + view.addSubview(playerView) + playerViewController.didMove(toParent: self) + playerView.backgroundColor = .black + + addChild(playbackControlsViewController) + view.addSubview(playbackControlsView) + playbackControlsViewController.didMove(toParent: self) + playbackControlsView.backgroundColor = .clear + + addChild(supplementContainerViewController) + view.addSubview(supplementContainerView) + supplementContainerViewController.didMove(toParent: self) + supplementContainerView.backgroundColor = .clear + } + + private func setupConstraints() { + playerRegularConstraints = [ + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + NSLayoutConstraint.activate(playerRegularConstraints) + + supplementBottomAnchor = supplementContainerView.topAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -(100 + EdgeInsets.edgePadding) + ) + containerState.supplementOffset = supplementBottomAnchor.constant + + let constant = (500 + EdgeInsets.edgePadding * 2) + supplementHeightAnchor = supplementContainerView.heightAnchor.constraint(equalToConstant: constant) + + supplementRegularConstraints = [ + supplementContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + supplementContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + supplementBottomAnchor, + supplementHeightAnchor, + ] + + NSLayoutConstraint.activate(supplementRegularConstraints) + + playbackControlsConstraints = [ + playbackControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playbackControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playbackControlsView.topAnchor.constraint(equalTo: view.topAnchor), + playbackControlsView.bottomAnchor.constraint(equalTo: supplementContainerView.topAnchor), + ] + + NSLayoutConstraint.activate(playbackControlsConstraints) + } + + @objc + func ignorePress() {} + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + print(presses) + guard let buttonPress = presses.first else { return } + + onPressEvent.send((type: buttonPress.type, phase: buttonPress.phase)) + } + } +} + +extension VideoPlayer.UIVideoPlayerContainerViewController { + + typealias PressEvent = (type: UIPress.PressType, phase: UIPress.Phase) + typealias OnPressEvent = LegacyEventPublisher +} + +@propertyWrapper +struct OnPressEvent: DynamicProperty { + + @Environment(\.onPressEventPublisher) + private var publisher + + var wrappedValue: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent { + publisher + } +} + +extension EnvironmentValues { + + @Entry + var onPressEventPublisher: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent = .init() +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj new file mode 100644 index 00000000..4ba262a0 --- /dev/null +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -0,0 +1,1768 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 71; + objects = { + +/* Begin PBXBuildFile section */ + 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53ABFDDB267972BF00886593 /* TVServices.framework */; }; + 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; + 62666DF827E5012C00EC0ECD /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; }; + 62666DFB27E5013700EC0ECD /* TVVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4AE267D40B5000E2F71 /* AudioToolbox.framework */; }; + 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */; }; + 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4B0267D40B9000E2F71 /* CFNetwork.framework */; }; + 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E0027E5016900EC0ECD /* CoreFoundation.framework */; }; + 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4B6267D40CA000E2F71 /* CoreGraphics.framework */; }; + 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4B8267D40CE000E2F71 /* CoreMedia.framework */; }; + 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BA267D40D2000E2F71 /* CoreText.framework */; }; + 62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E0527E5017A00EC0ECD /* CoreVideo.framework */; }; + 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E0B27E501A500EC0ECD /* OpenGLES.framework */; }; + 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C2267D40EC000E2F71 /* QuartzCore.framework */; }; + 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C4267D40F0000E2F71 /* Security.framework */; }; + 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E0F27E501B400EC0ECD /* VideoToolbox.framework */; }; + 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C8267D40F7000E2F71 /* UIKit.framework */; }; + 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1227E501C300EC0ECD /* AudioToolbox.framework */; }; + 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1427E501C800EC0ECD /* AVFoundation.framework */; }; + 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1627E501CC00EC0ECD /* CFNetwork.framework */; }; + 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1827E501D000EC0ECD /* CoreFoundation.framework */; }; + 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1A27E501D400EC0ECD /* CoreGraphics.framework */; }; + 62666E1D27E501DB00EC0ECD /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1C27E501DB00EC0ECD /* CoreMedia.framework */; }; + 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E1E27E501DF00EC0ECD /* CoreText.framework */; }; + 62666E2127E501E400EC0ECD /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2027E501E400EC0ECD /* CoreVideo.framework */; }; + 62666E2327E501EB00EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2227E501EB00EC0ECD /* Foundation.framework */; }; + 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BC267D40D8000E2F71 /* Foundation.framework */; }; + 62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2927E5020A00EC0ECD /* OpenGLES.framework */; }; + 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2B27E5021000EC0ECD /* QuartzCore.framework */; }; + 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2D27E5021400EC0ECD /* Security.framework */; }; + 62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */; }; + 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E3127E5021E00EC0ECD /* UIKit.framework */; }; + 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 62666E3827E502CE00EC0ECD /* SwizzleSwift */; }; + 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */; }; + 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */; }; + BD88CB422D77E6A0006BB5E3 /* TVOSPicker in Frameworks */ = {isa = PBXBuildFile; productRef = BD88CB412D77E6A0006BB5E3 /* TVOSPicker */; }; + E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; + E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; + E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; + E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; }; + E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; }; + E1137D2F2E090C1A0091EB60 /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1137D2E2E090C1A0091EB60 /* VLCUI */; }; + E1137D312E090C230091EB60 /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1137D302E090C230091EB60 /* VLCUI */; }; + E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A62B5A178D009CAAAA /* CollectionHStack */; }; + E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A92B5A179A009CAAAA /* CollectionVGrid */; }; + E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E114DB322B1944FA00B75FB3 /* CollectionVGrid */; }; + E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA32BBA614F00424D36 /* CollectionVGrid */; }; + E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DAB2BBA6AD200424D36 /* CollectionHStack */; }; + E1153DD02BBB634F00424D36 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DCF2BBB634F00424D36 /* SVGKit */; }; + E1153DD22BBB649C00424D36 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DD12BBB649C00424D36 /* SVGKit */; }; + E1155ACB2D0584A90021557A /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1155ACA2D0584A90021557A /* IdentifiedCollections */; }; + E11ADE842E7E73B6008074FC /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E11ADE832E7E73B6008074FC /* StatefulMacros */; }; + E12186DE2718F1C50010884C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E12186DD2718F1C50010884C /* Defaults */; }; + E12B93072947CD0F00CE0BD9 /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E15210532946DF1B00375CC2 /* Pulse */; }; + E132D3C82BD200C10058A2DF /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E132D3C72BD200C10058A2DF /* CollectionVGrid */; }; + E134DD2C2E7F4DC300AED027 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E134DD2B2E7F4DC300AED027 /* StatefulMacros */; }; + E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; }; + E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FEC2BA218A80034110D /* SwiftUIIntrospect */; }; + E13AF3B628A0C598009093AB /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B528A0C598009093AB /* Nuke */; }; + E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B728A0C598009093AB /* NukeExtensions */; }; + E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B928A0C598009093AB /* NukeUI */; }; + E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3BB28A0C59E009093AB /* BlurHashKit */; }; + E13CCE4C2E6C08710070965F /* LNPopupUI-Static in Frameworks */ = {isa = PBXBuildFile; productRef = E13CCE4B2E6C08710070965F /* LNPopupUI-Static */; }; + E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3C52716499E009D4DAF /* CoreStore */; }; + E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CC27164CA7009D4DAF /* CoreStore */; }; + E13DD3D327168E65009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3D227168E65009D4DAF /* Defaults */; }; + E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E145EB4A2BE16849003BF6F3 /* KeychainSwift */; }; + E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */ = {isa = PBXBuildFile; productRef = E14EA1642BF70A8E00DE757A /* Mantis */; }; + E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C02BFD62FD00944FFA /* JellyfinAPI */; }; + E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C22BFD6DA200944FFA /* JellyfinAPI */; }; + E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; + E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; + E1523F822B132C350062821A /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1523F812B132C350062821A /* CollectionHStack */; }; + E1575E3C293C6B15001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E3B293C6B15001665B1 /* Files */; }; + E1575E58293E7685001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E57293E7685001665B1 /* Files */; }; + E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E15D4F042B1B0C3C00442DB8 /* PreferencesView */; }; + E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA832BA167350080E926 /* CollectionHStack */; }; + E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */; }; + E164308C2E3AA9710028D4E8 /* Transmission in Frameworks */ = {isa = PBXBuildFile; productRef = E164308B2E3AA9710028D4E8 /* Transmission */; }; + E175679A2E0375F300B90F41 /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E17567992E0375F300B90F41 /* VLCUI */; }; + E176EBDE2D050067009F4CF1 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E176EBDD2D050067009F4CF1 /* IdentifiedCollections */; }; + E176EBE02D0502A6009F4CF1 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E176EBDF2D0502A6009F4CF1 /* CollectionHStack */; }; + E176EBE32D0502C6009F4CF1 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E176EBE22D0502C6009F4CF1 /* CollectionHStack */; }; + E176EBE92D050925009F4CF1 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E176EBE82D050925009F4CF1 /* CollectionVGrid */; }; + E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; }; + E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E18D6AA52BAA96F000A0D167 /* CollectionHStack */; }; + E19138CA2E7E7FA20061E464 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E19138C92E7E7FA20061E464 /* StatefulMacros */; }; + E192608328D2D0DB002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608228D2D0DB002314B4 /* Factory */; }; + E192608828D2E5F0002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608728D2E5F0002314B4 /* Factory */; }; + E19D41B22BF2BFA50082B8B2 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E19D41B12BF2BFA50082B8B2 /* KeychainSwift */; }; + E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E19DDEC62948EF9900954E10 /* OrderedCollections */; }; + E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0428A0B958005C10C8 /* Nuke */; }; + E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0628A0B958005C10C8 /* NukeUI */; }; + E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0928A0BEFF005C10C8 /* BlurHashKit */; }; + E19FA1A02E84F0A800F5A60D /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E19FA19F2E84F0A800F5A60D /* StatefulMacros */; }; + E1A09F722D05933D00835265 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F712D05933D00835265 /* CollectionVGrid */; }; + E1A09F752D05935100835265 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F742D05935100835265 /* CollectionHStack */; }; + E1A09F772D05935A00835265 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F762D05935A00835265 /* CollectionVGrid */; }; + E1A09F792D05935A00835265 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F782D05935A00835265 /* CollectionHStack */; }; + E1A76F1A2E8369A500A5F2C1 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1A76F192E8369A500A5F2C1 /* StatefulMacros */; }; + E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; + E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A629577BCE004B26CF /* Pulse */; }; + E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A829577BCE004B26CF /* PulseLogHandler */; }; + E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AA29577BCE004B26CF /* PulseUI */; }; + E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; }; + E1B9743B2E86F51D008CED48 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1B9743A2E86F51D008CED48 /* StatefulMacros */; }; + E1B9743E2E86F7F9008CED48 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1B9743D2E86F7F9008CED48 /* StatefulMacros */; }; + E1B974402E86F802008CED48 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1B9743F2E86F802008CED48 /* StatefulMacros */; }; + E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC9813296DC06200982F06 /* PulseLogHandler */; }; + E1E2D7BF2E3FD936004E2E5F /* Transmission in Frameworks */ = {isa = PBXBuildFile; productRef = E1E2D7BE2E3FD936004E2E5F /* Transmission */; }; + E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E1FAD1C52A0375BA007F5521 /* UDPBroadcast */; }; + E1FADDF12E84B63600FB310E /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1FADDF02E84B63600FB310E /* StatefulMacros */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 62666DF927E5012C00EC0ECD /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 62666DF827E5012C00EC0ECD /* MobileVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 62666DFC27E5013700EC0ECD /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 62666DFB27E5013700EC0ECD /* TVVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 535870602669D21600D05A09 /* Swiftfin tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Swiftfin tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = ""; }; + 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + 5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; + 5362E4AE267D40B5000E2F71 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; + 5362E4B0267D40B9000E2F71 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; + 5362E4B2267D40BE000E2F71 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; + 5362E4B4267D40C5000E2F71 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; + 5362E4B6267D40CA000E2F71 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 5362E4B8267D40CE000E2F71 /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + 5362E4BA267D40D2000E2F71 /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; + 5362E4BC267D40D8000E2F71 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaAccessibility.framework; path = System/Library/Frameworks/MediaAccessibility.framework; sourceTree = SDKROOT; }; + 5362E4C0267D40E8000E2F71 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; + 5362E4C2267D40EC000E2F71 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + 5362E4C4267D40F0000E2F71 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; + 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Swiftfin iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; + 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; + 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TVVLCKit.xcframework; path = Carthage/Build/TVVLCKit.xcframework; sourceTree = ""; }; + 62666E0027E5016900EC0ECD /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + 62666E0527E5017A00EC0ECD /* CoreVideo.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreVideo.framework; path = System/Library/Frameworks/CoreVideo.framework; sourceTree = SDKROOT; }; + 62666E0727E5018D00EC0ECD /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; }; + 62666E0827E5019800EC0ECD /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + 62666E0927E5019C00EC0ECD /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; }; + 62666E0A27E501A100EC0ECD /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; }; + 62666E0B27E501A500EC0ECD /* OpenGLES.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OpenGLES.framework; path = System/Library/Frameworks/OpenGLES.framework; sourceTree = SDKROOT; }; + 62666E0F27E501B400EC0ECD /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; }; + 62666E1227E501C300EC0ECD /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/AudioToolbox.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1427E501C800EC0ECD /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1627E501CC00EC0ECD /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CFNetwork.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1827E501D000EC0ECD /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CoreFoundation.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1A27E501D400EC0ECD /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CoreGraphics.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1C27E501DB00EC0ECD /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CoreMedia.framework; sourceTree = DEVELOPER_DIR; }; + 62666E1E27E501DF00EC0ECD /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CoreText.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2027E501E400EC0ECD /* CoreVideo.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreVideo.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/CoreVideo.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2227E501EB00EC0ECD /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2527E501FA00EC0ECD /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/usr/lib/libbz2.tbd; sourceTree = DEVELOPER_DIR; }; + 62666E2627E501FE00EC0ECD /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/usr/lib/libc++.tbd"; sourceTree = DEVELOPER_DIR; }; + 62666E2727E5020200EC0ECD /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; + 62666E2827E5020600EC0ECD /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/usr/lib/libxml2.tbd; sourceTree = DEVELOPER_DIR; }; + 62666E2927E5020A00EC0ECD /* OpenGLES.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OpenGLES.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/OpenGLES.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2B27E5021000EC0ECD /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/QuartzCore.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2D27E5021400EC0ECD /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/Security.framework; sourceTree = DEVELOPER_DIR; }; + 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/VideoToolbox.framework; sourceTree = DEVELOPER_DIR; }; + 62666E3127E5021E00EC0ECD /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/UIKit.framework; sourceTree = DEVELOPER_DIR; }; + 62666E3A27E503E400EC0ECD /* GoogleCastSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCastSDK.xcframework; path = Frameworks/GoogleCastSDK.xcframework; sourceTree = ""; }; + 628B95212670CABD0091AF3B /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 628B95232670CABD0091AF3B /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UDPBroadcast.xcframework; path = Carthage/Build/UDPBroadcast.xcframework; sourceTree = ""; }; + E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Carthage/Build/GoogleCast.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E14561A22DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 5377CBF0263B596A003A4E83 /* Swiftfin iOS */; + }; + E14561A32DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Assets.xcassets, + ); + target = 5358705F2669D21600D05A09 /* Swiftfin tvOS */; + }; + E14565D92DFCAE6E008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Extensions/JellyfinAPI/TaskTriggerType.swift, + Objects/ItemArrayElements.swift, + ViewModels/AdminDashboard/ActiveSessionsViewModel.swift, + ViewModels/AdminDashboard/AddServerUserViewModel.swift, + ViewModels/AdminDashboard/APIKeysViewModel.swift, + ViewModels/AdminDashboard/DevicesViewModel.swift, + ViewModels/AdminDashboard/ServerActivityDetailViewModel.swift, + ViewModels/AdminDashboard/ServerActivityViewModel.swift, + ViewModels/AdminDashboard/ServerTaskObserver.swift, + ViewModels/AdminDashboard/ServerTasksViewModel.swift, + ViewModels/AdminDashboard/ServerUserAdminViewModel.swift, + ViewModels/AdminDashboard/ServerUsersViewModel.swift, + ViewModels/DownloadListViewModel.swift, + ViewModels/ItemAdministration/IdentifyItemViewModel.swift, + ViewModels/ItemAdministration/ItemEditorViewModel/GenreEditorViewModel.swift, + ViewModels/ItemAdministration/ItemEditorViewModel/ItemEditorViewModel.swift, + ViewModels/ItemAdministration/ItemEditorViewModel/PeopleEditorViewModel.swift, + ViewModels/ItemAdministration/ItemEditorViewModel/StudioEditorViewModel.swift, + ViewModels/ItemAdministration/ItemEditorViewModel/TagEditorViewModel.swift, + ViewModels/ItemAdministration/ItemImagesViewModel.swift, + ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift, + ViewModels/QuickConnectAuthorizeViewModel.swift, + ViewModels/ServerCheckViewModel.swift, + ); + target = 5358705F2669D21600D05A09 /* Swiftfin tvOS */; + }; + E14567272DFCAEFD008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 5358705F2669D21600D05A09 /* Swiftfin tvOS */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E14560852DFCAE51008FF700 /* Swiftfin */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14561A22DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E14561A32DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Swiftfin; sourceTree = ""; }; + E14563272DFCAE6E008FF700 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14565D92DFCAE6E008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; + E14565DD2DFCAE78008FF700 /* Scripts */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Scripts; sourceTree = ""; }; + E145669F2DFCAEFD008FF700 /* Swiftfin tvOS */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14567272DFCAEFD008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Swiftfin tvOS"; sourceTree = ""; }; + E1456FC82DFCB323008FF700 /* Translations */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Translations; sourceTree = ""; }; + E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = XcodeConfig; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5358705D2669D21600D05A09 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E1137D312E090C230091EB60 /* VLCUI in Frameworks */, + 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */, + 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, + 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, + 62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */, + E1002B6B2793E36600E47059 /* Algorithms in Frameworks */, + 62666E1D27E501DB00EC0ECD /* CoreMedia.framework in Frameworks */, + 62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */, + BD88CB422D77E6A0006BB5E3 /* TVOSPicker in Frameworks */, + 62666E2327E501EB00EC0ECD /* Foundation.framework in Frameworks */, + 62666E2127E501E400EC0ECD /* CoreVideo.framework in Frameworks */, + E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */, + E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */, + E1575E58293E7685001665B1 /* Files in Frameworks */, + E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */, + E1A09F792D05935A00835265 /* CollectionHStack in Frameworks */, + E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */, + E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */, + E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */, + 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */, + E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */, + 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, + E1B974402E86F802008CED48 /* StatefulMacros in Frameworks */, + 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, + E19D41B22BF2BFA50082B8B2 /* KeychainSwift in Frameworks */, + E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */, + E1155ACB2D0584A90021557A /* IdentifiedCollections in Frameworks */, + 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, + E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */, + 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, + 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, + E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, + E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */, + E1A09F772D05935A00835265 /* CollectionVGrid in Frameworks */, + E1153DD22BBB649C00424D36 /* SVGKit in Frameworks */, + 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, + E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, + E150C0C32BFD6DA200944FFA /* JellyfinAPI in Frameworks */, + 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, + E1E2D7BF2E3FD936004E2E5F /* Transmission in Frameworks */, + E13AF3B628A0C598009093AB /* Nuke in Frameworks */, + E12186DE2718F1C50010884C /* Defaults in Frameworks */, + E192608828D2E5F0002314B4 /* Factory in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5377CBEE263B596A003A4E83 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E12B93072947CD0F00CE0BD9 /* Pulse in Frameworks */, + 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */, + 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */, + E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, + E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, + E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, + 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, + E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, + E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, + 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, + 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, + E1B9743B2E86F51D008CED48 /* StatefulMacros in Frameworks */, + E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */, + 62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */, + E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */, + E10706102942F57D00646DAF /* Pulse in Frameworks */, + E176EBE92D050925009F4CF1 /* CollectionVGrid in Frameworks */, + E192608328D2D0DB002314B4 /* Factory in Frameworks */, + E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */, + E1B9743E2E86F7F9008CED48 /* StatefulMacros in Frameworks */, + E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, + E19138CA2E7E7FA20061E464 /* StatefulMacros in Frameworks */, + E1523F822B132C350062821A /* CollectionHStack in Frameworks */, + E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */, + E10706142942F57D00646DAF /* PulseUI in Frameworks */, + E175679A2E0375F300B90F41 /* VLCUI in Frameworks */, + E13CCE4C2E6C08710070965F /* LNPopupUI-Static in Frameworks */, + 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, + E1575E3C293C6B15001665B1 /* Files in Frameworks */, + E1137D2F2E090C1A0091EB60 /* VLCUI in Frameworks */, + E176EBE02D0502A6009F4CF1 /* CollectionHStack in Frameworks */, + E1FADDF12E84B63600FB310E /* StatefulMacros in Frameworks */, + E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */, + E176EBDE2D050067009F4CF1 /* IdentifiedCollections in Frameworks */, + 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, + 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, + E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, + E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */, + E1153DD02BBB634F00424D36 /* SVGKit in Frameworks */, + E19FA1A02E84F0A800F5A60D /* StatefulMacros in Frameworks */, + E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */, + 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, + 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, + E132D3C82BD200C10058A2DF /* CollectionVGrid in Frameworks */, + E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */, + E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */, + E11ADE842E7E73B6008074FC /* StatefulMacros in Frameworks */, + 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */, + E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */, + E176EBE32D0502C6009F4CF1 /* CollectionHStack in Frameworks */, + 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */, + E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */, + E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */, + E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */, + 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, + 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */, + E1A09F722D05933D00835265 /* CollectionVGrid in Frameworks */, + E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */, + E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */, + 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */, + E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */, + E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */, + E134DD2C2E7F4DC300AED027 /* StatefulMacros in Frameworks */, + E1A76F1A2E8369A500A5F2C1 /* StatefulMacros in Frameworks */, + 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */, + E1A09F752D05935100835265 /* CollectionHStack in Frameworks */, + E164308C2E3AA9710028D4E8 /* Transmission in Frameworks */, + 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5377CBE8263B596A003A4E83 = { + isa = PBXGroup; + children = ( + E14560852DFCAE51008FF700 /* Swiftfin */, + E145669F2DFCAEFD008FF700 /* Swiftfin tvOS */, + E14563272DFCAE6E008FF700 /* Shared */, + E1456FC82DFCB323008FF700 /* Translations */, + 5377CBF2263B596A003A4E83 /* Products */, + 53D5E3DB264B47EE00BADDC8 /* Frameworks */, + E14565DD2DFCAE78008FF700 /* Scripts */, + E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */, + ); + sourceTree = ""; + }; + 5377CBF2263B596A003A4E83 /* Products */ = { + isa = PBXGroup; + children = ( + 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */, + 535870602669D21600D05A09 /* Swiftfin tvOS.app */, + ); + name = Products; + sourceTree = ""; + }; + 53D5E3DB264B47EE00BADDC8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */, + 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */, + 62666E3A27E503E400EC0ECD /* GoogleCastSDK.xcframework */, + 62666E3127E5021E00EC0ECD /* UIKit.framework */, + 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */, + 62666E2D27E5021400EC0ECD /* Security.framework */, + 62666E2B27E5021000EC0ECD /* QuartzCore.framework */, + 62666E2927E5020A00EC0ECD /* OpenGLES.framework */, + 62666E2827E5020600EC0ECD /* libxml2.tbd */, + 62666E2727E5020200EC0ECD /* libiconv.tbd */, + 62666E2627E501FE00EC0ECD /* libc++.tbd */, + 62666E2527E501FA00EC0ECD /* libbz2.tbd */, + 62666E2227E501EB00EC0ECD /* Foundation.framework */, + 62666E2027E501E400EC0ECD /* CoreVideo.framework */, + 62666E1E27E501DF00EC0ECD /* CoreText.framework */, + 62666E1C27E501DB00EC0ECD /* CoreMedia.framework */, + 62666E1A27E501D400EC0ECD /* CoreGraphics.framework */, + 62666E1827E501D000EC0ECD /* CoreFoundation.framework */, + 62666E1627E501CC00EC0ECD /* CFNetwork.framework */, + 62666E1427E501C800EC0ECD /* AVFoundation.framework */, + 62666E1227E501C300EC0ECD /* AudioToolbox.framework */, + 62666E0F27E501B400EC0ECD /* VideoToolbox.framework */, + 62666E0B27E501A500EC0ECD /* OpenGLES.framework */, + 62666E0A27E501A100EC0ECD /* libxml2.tbd */, + 62666E0927E5019C00EC0ECD /* libiconv.tbd */, + 62666E0827E5019800EC0ECD /* libc++.tbd */, + 62666E0727E5018D00EC0ECD /* libbz2.tbd */, + 62666E0527E5017A00EC0ECD /* CoreVideo.framework */, + 62666E0027E5016900EC0ECD /* CoreFoundation.framework */, + 5362E4C8267D40F7000E2F71 /* UIKit.framework */, + 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */, + 5362E4C4267D40F0000E2F71 /* Security.framework */, + 5362E4C2267D40EC000E2F71 /* QuartzCore.framework */, + 5362E4C0267D40E8000E2F71 /* MediaPlayer.framework */, + 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */, + 5362E4BC267D40D8000E2F71 /* Foundation.framework */, + 5362E4BA267D40D2000E2F71 /* CoreText.framework */, + 5362E4B8267D40CE000E2F71 /* CoreMedia.framework */, + 5362E4B6267D40CA000E2F71 /* CoreGraphics.framework */, + 5362E4B4267D40C5000E2F71 /* CoreData.framework */, + 5362E4B2267D40BE000E2F71 /* CoreBluetooth.framework */, + 5362E4B0267D40B9000E2F71 /* CFNetwork.framework */, + 5362E4AE267D40B5000E2F71 /* AudioToolbox.framework */, + 5362E4AC267D40B1000E2F71 /* Accelerate.framework */, + 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */, + 5362E4A7267D4067000E2F71 /* GoogleCast.framework */, + 53ABFDDB267972BF00886593 /* TVServices.framework */, + 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */, + 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */, + 628B95212670CABD0091AF3B /* WidgetKit.framework */, + 628B95232670CABD0091AF3B /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5358705F2669D21600D05A09 /* Swiftfin tvOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "Swiftfin tvOS" */; + buildPhases = ( + 4EC71FBD2D1620AF00D0B3A8 /* Alphabetize Strings */, + 6286F0A3271C0ABA00C40ED5 /* Run Swiftgen.swift */, + BD83D7852B55EEB600652C24 /* Run SwiftFormat */, + 5358705C2669D21600D05A09 /* Sources */, + 5358705D2669D21600D05A09 /* Frameworks */, + 5358705E2669D21600D05A09 /* Resources */, + 62666DFC27E5013700EC0ECD /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E14563272DFCAE6E008FF700 /* Shared */, + E145669F2DFCAEFD008FF700 /* Swiftfin tvOS */, + E1456FC82DFCB323008FF700 /* Translations */, + E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */, + ); + name = "Swiftfin tvOS"; + packageProductDependencies = ( + E13DD3CC27164CA7009D4DAF /* CoreStore */, + E12186DD2718F1C50010884C /* Defaults */, + E1002B6A2793E36600E47059 /* Algorithms */, + E13AF3B528A0C598009093AB /* Nuke */, + E13AF3B728A0C598009093AB /* NukeExtensions */, + E13AF3B928A0C598009093AB /* NukeUI */, + E13AF3BB28A0C59E009093AB /* BlurHashKit */, + E192608728D2E5F0002314B4 /* Factory */, + E1575E57293E7685001665B1 /* Files */, + E1388A45293F0ABA009721B1 /* SwizzleSwift */, + E1B5F7A629577BCE004B26CF /* Pulse */, + E1B5F7A829577BCE004B26CF /* PulseLogHandler */, + E1B5F7AA29577BCE004B26CF /* PulseUI */, + E1B5F7AC29577BDD004B26CF /* OrderedCollections */, + E18443CA2A037773002DDDC8 /* UDPBroadcast */, + E1A7B1642B9A9F7800152546 /* PreferencesView */, + E1392FEC2BA218A80034110D /* SwiftUIIntrospect */, + E1153DD12BBB649C00424D36 /* SVGKit */, + E19D41B12BF2BFA50082B8B2 /* KeychainSwift */, + E150C0C22BFD6DA200944FFA /* JellyfinAPI */, + E1155ACA2D0584A90021557A /* IdentifiedCollections */, + E1A09F762D05935A00835265 /* CollectionVGrid */, + E1A09F782D05935A00835265 /* CollectionHStack */, + BD88CB412D77E6A0006BB5E3 /* TVOSPicker */, + E1137D302E090C230091EB60 /* VLCUI */, + E1E2D7BE2E3FD936004E2E5F /* Transmission */, + E1B9743F2E86F802008CED48 /* StatefulMacros */, + ); + productName = "JellyfinPlayer tvOS"; + productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; + productType = "com.apple.product-type.application"; + }; + 5377CBF0263B596A003A4E83 /* Swiftfin iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5377CC1B263B596B003A4E83 /* Build configuration list for PBXNativeTarget "Swiftfin iOS" */; + buildPhases = ( + 4EC71FBC2D16201C00D0B3A8 /* Alphabetize Strings */, + 6286F09E271C093000C40ED5 /* Run Swiftgen.swift */, + BD0BA2282AD64BB200306A8D /* Run SwiftFormat */, + 5377CBED263B596A003A4E83 /* Sources */, + 5377CBEE263B596A003A4E83 /* Frameworks */, + 5377CBEF263B596A003A4E83 /* Resources */, + 62666DF927E5012C00EC0ECD /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E14560852DFCAE51008FF700 /* Swiftfin */, + E14563272DFCAE6E008FF700 /* Shared */, + E1456FC82DFCB323008FF700 /* Translations */, + E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */, + ); + name = "Swiftfin iOS"; + packageProductDependencies = ( + E13DD3C52716499E009D4DAF /* CoreStore */, + E13DD3D227168E65009D4DAF /* Defaults */, + E1002B672793CFBA00E47059 /* Algorithms */, + 62666E3827E502CE00EC0ECD /* SwizzleSwift */, + E19E6E0428A0B958005C10C8 /* Nuke */, + E19E6E0628A0B958005C10C8 /* NukeUI */, + E19E6E0928A0BEFF005C10C8 /* BlurHashKit */, + E192608228D2D0DB002314B4 /* Factory */, + E1575E3B293C6B15001665B1 /* Files */, + E15210532946DF1B00375CC2 /* Pulse */, + E15210552946DF1B00375CC2 /* PulseLogHandler */, + E15210572946DF1B00375CC2 /* PulseUI */, + E19DDEC62948EF9900954E10 /* OrderedCollections */, + E1DC9813296DC06200982F06 /* PulseLogHandler */, + E1FAD1C52A0375BA007F5521 /* UDPBroadcast */, + E1523F812B132C350062821A /* CollectionHStack */, + E114DB322B1944FA00B75FB3 /* CollectionVGrid */, + E15D4F042B1B0C3C00442DB8 /* PreferencesView */, + E113A2A62B5A178D009CAAAA /* CollectionHStack */, + E113A2A92B5A179A009CAAAA /* CollectionVGrid */, + E15EFA832BA167350080E926 /* CollectionHStack */, + E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */, + E18D6AA52BAA96F000A0D167 /* CollectionHStack */, + E1153DA32BBA614F00424D36 /* CollectionVGrid */, + E1153DAB2BBA6AD200424D36 /* CollectionHStack */, + E1153DCF2BBB634F00424D36 /* SVGKit */, + E132D3C72BD200C10058A2DF /* CollectionVGrid */, + E145EB4A2BE16849003BF6F3 /* KeychainSwift */, + E14EA1642BF70A8E00DE757A /* Mantis */, + E150C0C02BFD62FD00944FFA /* JellyfinAPI */, + E176EBDD2D050067009F4CF1 /* IdentifiedCollections */, + E176EBDF2D0502A6009F4CF1 /* CollectionHStack */, + E176EBE22D0502C6009F4CF1 /* CollectionHStack */, + E176EBE82D050925009F4CF1 /* CollectionVGrid */, + E1A09F712D05933D00835265 /* CollectionVGrid */, + E1A09F742D05935100835265 /* CollectionHStack */, + E17567992E0375F300B90F41 /* VLCUI */, + E1137D2E2E090C1A0091EB60 /* VLCUI */, + E164308B2E3AA9710028D4E8 /* Transmission */, + E13CCE4B2E6C08710070965F /* LNPopupUI-Static */, + E11ADE832E7E73B6008074FC /* StatefulMacros */, + E19138C92E7E7FA20061E464 /* StatefulMacros */, + E134DD2B2E7F4DC300AED027 /* StatefulMacros */, + E1A76F192E8369A500A5F2C1 /* StatefulMacros */, + E1FADDF02E84B63600FB310E /* StatefulMacros */, + E19FA19F2E84F0A800F5A60D /* StatefulMacros */, + E1B9743A2E86F51D008CED48 /* StatefulMacros */, + E1B9743D2E86F7F9008CED48 /* StatefulMacros */, + ); + productName = JellyfinPlayer; + productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5377CBE9263B596A003A4E83 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + KnownAssetTags = ( + New, + ); + LastSwiftUpdateCheck = 1250; + LastUpgradeCheck = 1610; + TargetAttributes = { + 5358705F2669D21600D05A09 = { + CreatedOnToolsVersion = 12.5; + }; + 5377CBF0263B596A003A4E83 = { + CreatedOnToolsVersion = 12.5; + }; + }; + }; + buildConfigurationList = 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "Swiftfin" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + "zh-Hans", + ko, + fr, + ru, + it, + vi, + sv, + sl, + de, + ta, + es, + el, + he, + sk, + kk, + Base, + ar, + bg, + ca, + cs, + da, + eo, + eu, + fi, + hi, + hr, + hu, + id, + ja, + lb, + lt, + mk, + "nb-NO", + nl, + nn, + pl, + ps, + pt, + "pt-BR", + ro, + sq, + th, + tr, + uk, + "zh-Hant", + mn, + ); + mainGroup = 5377CBE8263B596A003A4E83; + packageReferences = ( + 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */, + E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, + E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */, + 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */, + E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */, + E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */, + E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */, + E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */, + E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */, + E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */, + E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */, + E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */, + E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */, + E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */, + E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */, + E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */, + E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, + E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */, + E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */, + BD88CB402D77E6A0006BB5E3 /* XCRemoteSwiftPackageReference "TVOSPicker" */, + E1137D2D2E090C1A0091EB60 /* XCRemoteSwiftPackageReference "VLCUI" */, + E164308A2E3AA9710028D4E8 /* XCRemoteSwiftPackageReference "Transmission" */, + E13CCE482E6C077D0070965F /* XCRemoteSwiftPackageReference "LNPopupUI" */, + E1B9743C2E86F7F9008CED48 /* XCRemoteSwiftPackageReference "StatefulMacro" */, + ); + productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5377CBF0263B596A003A4E83 /* Swiftfin iOS */, + 5358705F2669D21600D05A09 /* Swiftfin tvOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5358705E2669D21600D05A09 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5377CBEF263B596A003A4E83 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 4EC71FBC2D16201C00D0B3A8 /* Alphabetize Strings */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Translations/en.lproj/Localizable.strings", + ); + name = "Alphabetize Strings"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/alphabetizeStrings.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "xcrun --sdk macosx swift \"${SRCROOT}/Scripts/Translations/AlphabetizeStrings.swift\"\n"; + }; + 4EC71FBD2D1620AF00D0B3A8 /* Alphabetize Strings */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Translations/en.lproj/Localizable.strings", + ); + name = "Alphabetize Strings"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/alphabetizeStrings.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "xcrun --sdk macosx swift \"${SRCROOT}/Scripts/Translations/AlphabetizeStrings.swift\"\n"; + }; + 6286F09E271C093000C40ED5 /* Run Swiftgen.swift */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Swiftgen.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Add Homebrew to the path to support Apple Silicon Homebrew SwiftGen installations\nexport PATH=/opt/homebrew/bin:$PATH\n\nif which swiftgen >/dev/null; then\n swiftgen\nelse\n echo \"error: SwiftGen not installed, check contributing.md for installation instructions.\"\n return 1\nfi\n"; + }; + 6286F0A3271C0ABA00C40ED5 /* Run Swiftgen.swift */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Swiftgen.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Add Homebrew to the path to support Apple Silicon Homebrew SwiftGen installations\nexport PATH=/opt/homebrew/bin:$PATH \n\nif which swiftgen >/dev/null; then\n swiftgen\nelse\n echo \"error: SwiftGen not installed, check contributing.md for installation instructions.\"\n return 1\nfi\n"; + }; + BD0BA2282AD64BB200306A8D /* Run SwiftFormat */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run SwiftFormat"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Add Homebrew to the path to support Apple Silicon Homebrew SwiftFormat installations\nexport PATH=/opt/homebrew/bin:$PATH\n\n# Skip phase if the action is not build (ie. analyze, archive, etc).\nif [ \"$ACTION\" != \"build\" ]; then\n exit 0\nfi\n\nif which swiftformat >/dev/null; then\n swiftformat .\nelse\n echo \"error: SwiftFormat not installed, check contributing.md for installation instructions.\"\nfi\n"; + }; + BD83D7852B55EEB600652C24 /* Run SwiftFormat */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run SwiftFormat"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Add Homebrew to the path to support Apple Silicon Homebrew SwiftFormat installations\nexport PATH=/opt/homebrew/bin:$PATH\n\n# Skip phase if the action is not build (ie. analyze, archive, etc).\nif [ \"$ACTION\" != \"build\" ]; then\n exit 0\nfi\n\nif which swiftformat >/dev/null; then\n swiftformat .\nelse\n echo \"error: SwiftFormat not installed, check contributing.md for installation instructions.\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5358705C2669D21600D05A09 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5377CBED263B596A003A4E83 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 535870722669D21700D05A09 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 70; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = "Swiftfin tvOS/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + 535870732669D21700D05A09 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 70; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = "Swiftfin tvOS/Resources/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; + 5377CC19263B596B003A4E83 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */; + baseConfigurationReferenceRelativePath = Shared.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + EXCLUDED_ARCHS = ""; + "EXCLUDED_ARCHS[sdk=*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5377CC1A263B596B003A4E83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + EXCLUDED_ARCHS = ""; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5377CC1C263B596B003A4E83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-primary-primary"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Swiftfin/Resources/Swiftfin.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 78; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_ARCHS = ""; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Swiftfin/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftfin; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_CFLAGS = ""; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5377CC1D263B596B003A4E83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-primary-primary"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = Swiftfin/Resources/Swiftfin.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 78; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_ARCHS = ""; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Swiftfin/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftfin; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_CFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "Swiftfin tvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 535870722669D21700D05A09 /* Debug */, + 535870732669D21700D05A09 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "Swiftfin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5377CC19263B596B003A4E83 /* Debug */, + 5377CC1A263B596B003A4E83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5377CC1B263B596B003A4E83 /* Build configuration list for PBXNativeTarget "Swiftfin iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5377CC1C263B596B003A4E83 /* Debug */, + 5377CC1D263B596B003A4E83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = PreferencesView; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MarioIannotta/SwizzleSwift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + BD88CB402D77E6A0006BB5E3 /* XCRemoteSwiftPackageReference "TVOSPicker" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ViacomInc/TVOSPicker"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.3.0; + }; + }; + E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/Pulse"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + E1137D2D2E090C1A0091EB60 /* XCRemoteSwiftPackageReference "VLCUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/VLCUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.7.2; + }; + }; + E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SVGKit/SVGKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; + E13CCE482E6C077D0070965F /* XCRemoteSwiftPackageReference "LNPopupUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LeoNatan/LNPopupUI/"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 2.0.0; + }; + }; + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/JohnEstropia/CoreStore.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.0.0; + }; + }; + E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/Defaults"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; + E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/evgenyneu/keychain-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 24.0.0; + }; + }; + E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/guoyingtao/Mantis"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.5.2; + }; + }; + E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/Pulse"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.0; + }; + }; + E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/JohnSundell/Files"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; + E164308A2E3AA9710028D4E8 /* XCRemoteSwiftPackageReference "Transmission" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/nathantannar4/Transmission"; + requirement = { + kind = exactVersion; + version = 2.4.5; + }; + }; + E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-identified-collections"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; + E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/hmlongco/Factory"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; + E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/Nuke"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.0.0; + }; + }; + E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/BlurHashKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionVGrid"; + requirement = { + branch = main; + kind = branch; + }; + }; + E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionHStack"; + requirement = { + branch = main; + kind = branch; + }; + }; + E1B9743C2E86F7F9008CED48 /* XCRemoteSwiftPackageReference "StatefulMacro" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/StatefulMacro"; + requirement = { + branch = main; + kind = branch; + }; + }; + E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/PulseLogHandler"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.0; + }; + }; + E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gunterhager/UDPBroadcastConnection"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 62666E3827E502CE00EC0ECD /* SwizzleSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */; + productName = SwizzleSwift; + }; + BD88CB412D77E6A0006BB5E3 /* TVOSPicker */ = { + isa = XCSwiftPackageProductDependency; + package = BD88CB402D77E6A0006BB5E3 /* XCRemoteSwiftPackageReference "TVOSPicker" */; + productName = TVOSPicker; + }; + E1002B672793CFBA00E47059 /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; + E1002B6A2793E36600E47059 /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; + E107060F2942F57D00646DAF /* Pulse */ = { + isa = XCSwiftPackageProductDependency; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; + }; + E10706112942F57D00646DAF /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; + }; + E10706132942F57D00646DAF /* PulseUI */ = { + isa = XCSwiftPackageProductDependency; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; + }; + E1137D2E2E090C1A0091EB60 /* VLCUI */ = { + isa = XCSwiftPackageProductDependency; + package = E1137D2D2E090C1A0091EB60 /* XCRemoteSwiftPackageReference "VLCUI" */; + productName = VLCUI; + }; + E1137D302E090C230091EB60 /* VLCUI */ = { + isa = XCSwiftPackageProductDependency; + package = E1137D2D2E090C1A0091EB60 /* XCRemoteSwiftPackageReference "VLCUI" */; + productName = VLCUI; + }; + E113A2A62B5A178D009CAAAA /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E113A2A92B5A179A009CAAAA /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E114DB322B1944FA00B75FB3 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E1153DA32BBA614F00424D36 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E1153DAB2BBA6AD200424D36 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E1153DCF2BBB634F00424D36 /* SVGKit */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKit; + }; + E1153DD12BBB649C00424D36 /* SVGKit */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DCE2BBB634F00424D36 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKit; + }; + E1155ACA2D0584A90021557A /* IdentifiedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = IdentifiedCollections; + }; + E11ADE832E7E73B6008074FC /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E12186DD2718F1C50010884C /* Defaults */ = { + isa = XCSwiftPackageProductDependency; + package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; + productName = Defaults; + }; + E132D3C72BD200C10058A2DF /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E134DD2B2E7F4DC300AED027 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E1388A45293F0ABA009721B1 /* SwizzleSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */; + productName = SwizzleSwift; + }; + E1392FEC2BA218A80034110D /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; + E13AF3B528A0C598009093AB /* Nuke */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = Nuke; + }; + E13AF3B728A0C598009093AB /* NukeExtensions */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeExtensions; + }; + E13AF3B928A0C598009093AB /* NukeUI */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeUI; + }; + E13AF3BB28A0C59E009093AB /* BlurHashKit */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */; + productName = BlurHashKit; + }; + E13CCE4B2E6C08710070965F /* LNPopupUI-Static */ = { + isa = XCSwiftPackageProductDependency; + package = E13CCE482E6C077D0070965F /* XCRemoteSwiftPackageReference "LNPopupUI" */; + productName = "LNPopupUI-Static"; + }; + E13DD3C52716499E009D4DAF /* CoreStore */ = { + isa = XCSwiftPackageProductDependency; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; + productName = CoreStore; + }; + E13DD3CC27164CA7009D4DAF /* CoreStore */ = { + isa = XCSwiftPackageProductDependency; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; + productName = CoreStore; + }; + E13DD3D227168E65009D4DAF /* Defaults */ = { + isa = XCSwiftPackageProductDependency; + package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; + productName = Defaults; + }; + E145EB4A2BE16849003BF6F3 /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; + E14EA1642BF70A8E00DE757A /* Mantis */ = { + isa = XCSwiftPackageProductDependency; + package = E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */; + productName = Mantis; + }; + E150C0C02BFD62FD00944FFA /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E150C0C22BFD6DA200944FFA /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E15210532946DF1B00375CC2 /* Pulse */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; + }; + E15210552946DF1B00375CC2 /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; + }; + E15210572946DF1B00375CC2 /* PulseUI */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; + }; + E1523F812B132C350062821A /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E1575E3B293C6B15001665B1 /* Files */ = { + isa = XCSwiftPackageProductDependency; + package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; + productName = Files; + }; + E1575E57293E7685001665B1 /* Files */ = { + isa = XCSwiftPackageProductDependency; + package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; + productName = Files; + }; + E15D4F042B1B0C3C00442DB8 /* PreferencesView */ = { + isa = XCSwiftPackageProductDependency; + productName = PreferencesView; + }; + E15EFA832BA167350080E926 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; + E164308B2E3AA9710028D4E8 /* Transmission */ = { + isa = XCSwiftPackageProductDependency; + package = E164308A2E3AA9710028D4E8 /* XCRemoteSwiftPackageReference "Transmission" */; + productName = Transmission; + }; + E17567992E0375F300B90F41 /* VLCUI */ = { + isa = XCSwiftPackageProductDependency; + productName = VLCUI; + }; + E176EBDD2D050067009F4CF1 /* IdentifiedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = IdentifiedCollections; + }; + E176EBDF2D0502A6009F4CF1 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = CollectionHStack; + }; + E176EBE22D0502C6009F4CF1 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E176EBE82D050925009F4CF1 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E18443CA2A037773002DDDC8 /* UDPBroadcast */ = { + isa = XCSwiftPackageProductDependency; + package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; + productName = UDPBroadcast; + }; + E18D6AA52BAA96F000A0D167 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E19138C92E7E7FA20061E464 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E192608228D2D0DB002314B4 /* Factory */ = { + isa = XCSwiftPackageProductDependency; + package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */; + productName = Factory; + }; + E192608728D2E5F0002314B4 /* Factory */ = { + isa = XCSwiftPackageProductDependency; + package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */; + productName = Factory; + }; + E19D41B12BF2BFA50082B8B2 /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; + E19DDEC62948EF9900954E10 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; + E19E6E0428A0B958005C10C8 /* Nuke */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = Nuke; + }; + E19E6E0628A0B958005C10C8 /* NukeUI */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeUI; + }; + E19E6E0928A0BEFF005C10C8 /* BlurHashKit */ = { + isa = XCSwiftPackageProductDependency; + package = E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */; + productName = BlurHashKit; + }; + E19FA19F2E84F0A800F5A60D /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E1A09F712D05933D00835265 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; + E1A09F742D05935100835265 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; + E1A09F762D05935A00835265 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; + E1A09F782D05935A00835265 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; + E1A76F192E8369A500A5F2C1 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E1A7B1642B9A9F7800152546 /* PreferencesView */ = { + isa = XCSwiftPackageProductDependency; + package = E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */; + productName = PreferencesView; + }; + E1B5F7A629577BCE004B26CF /* Pulse */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; + }; + E1B5F7A829577BCE004B26CF /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; + }; + E1B5F7AA29577BCE004B26CF /* PulseUI */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; + }; + E1B5F7AC29577BDD004B26CF /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; + E1B9743A2E86F51D008CED48 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; + E1B9743D2E86F7F9008CED48 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + package = E1B9743C2E86F7F9008CED48 /* XCRemoteSwiftPackageReference "StatefulMacro" */; + productName = StatefulMacros; + }; + E1B9743F2E86F802008CED48 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + package = E1B9743C2E86F7F9008CED48 /* XCRemoteSwiftPackageReference "StatefulMacro" */; + productName = StatefulMacros; + }; + E1DC9813296DC06200982F06 /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */; + productName = PulseLogHandler; + }; + E1E2D7BE2E3FD936004E2E5F /* Transmission */ = { + isa = XCSwiftPackageProductDependency; + package = E164308A2E3AA9710028D4E8 /* XCRemoteSwiftPackageReference "Transmission" */; + productName = Transmission; + }; + E1FAD1C52A0375BA007F5521 /* UDPBroadcast */ = { + isa = XCSwiftPackageProductDependency; + package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; + productName = UDPBroadcast; + }; + E1FADDF02E84B63600FB310E /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5377CBE9263B596A003A4E83 /* Project object */; +} diff --git a/Swiftfin.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Swiftfin.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Swiftfin.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/jellypig.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from jellypig.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..6dd16513 --- /dev/null +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,339 @@ +{ + "originHash" : "38a493503cb22432e1bf1b0b812569bb2bdd51f821207a74b6b78ff16a049011", + "pins" : [ + { + "identity" : "blurhashkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/BlurHashKit", + "state" : { + "revision" : "c0bd7423398de68cbeb3f99bff70f79c38bf36ab", + "version" : "1.2.0" + } + }, + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" + } + }, + { + "identity" : "collectionhstack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/CollectionHStack", + "state" : { + "branch" : "main", + "revision" : "ce86c82ae46ba958d6e7f8459d592a77e1e299c5" + } + }, + { + "identity" : "collectionvgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/CollectionVGrid", + "state" : { + "branch" : "main", + "revision" : "09b979a3d25bc21e8da0fe6a2500dfca1c74334f" + } + }, + { + "identity" : "corestore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnEstropia/CoreStore.git", + "state" : { + "revision" : "5a0d27cf343c6e341b0ef3c8d36104770b27a839", + "version" : "9.3.0" + } + }, + { + "identity" : "defaults", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/Defaults", + "state" : { + "revision" : "38925e3cfacf3fb89a81a35b1cd44fd5a5b7e0fa", + "version" : "8.2.0" + } + }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit", + "state" : { + "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", + "version" : "1.3.0" + } + }, + { + "identity" : "engine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nathantannar4/Engine", + "state" : { + "revision" : "fe8ecdcfd7b82a431d5cd1400649b2cfc798cc9f", + "version" : "2.2.7" + } + }, + { + "identity" : "factory", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hmlongco/Factory", + "state" : { + "revision" : "ccc898f21992ebc130bc04cc197460a5ae230bcf", + "version" : "2.5.3" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files", + "state" : { + "revision" : "e85f2b4a8dfa0f242889f45236f3867d16e40480", + "version" : "4.3.0" + } + }, + { + "identity" : "get", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Get", + "state" : { + "revision" : "31249885da1052872e0ac91a2943f62567c0d96d", + "version" : "2.2.1" + } + }, + { + "identity" : "jellyfin-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jellyfin/jellyfin-sdk-swift.git", + "state" : { + "revision" : "3033d73e05d9e03571a67d579583b7d15377d0d3", + "version" : "0.5.3" + } + }, + { + "identity" : "keychain-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/evgenyneu/keychain-swift", + "state" : { + "revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608", + "version" : "24.0.0" + } + }, + { + "identity" : "lnpopupcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNPopupController.git", + "state" : { + "revision" : "e14712b8077f9d25c2b70d23241e922537673338", + "version" : "3.0.7" + } + }, + { + "identity" : "lnpopupui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNPopupUI/", + "state" : { + "revision" : "aa9c813f2b47d055b648628b738e1b115c5e9fad", + "version" : "2.0.2" + } + }, + { + "identity" : "lnswiftuiutils", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LeoNatan/LNSwiftUIUtils.git", + "state" : { + "revision" : "6681938f1f2c591736a912789ea80451200d1484", + "version" : "1.1.4" + } + }, + { + "identity" : "mantis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/guoyingtao/Mantis", + "state" : { + "revision" : "d471b02c6ab8c1432b612738b0f63eaff980df1c", + "version" : "2.27.0" + } + }, + { + "identity" : "nuke", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Nuke", + "state" : { + "revision" : "0ead44350d2737db384908569c012fe67c421e4d", + "version" : "12.8.0" + } + }, + { + "identity" : "pulse", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Pulse", + "state" : { + "revision" : "6125ce7fb51b114ba71b761d18cfd5557923bd4d", + "version" : "5.1.4" + } + }, + { + "identity" : "pulseloghandler", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/PulseLogHandler", + "state" : { + "revision" : "477e9ef76615f0b76b43bb502af432fe6750b704", + "version" : "5.1.0" + } + }, + { + "identity" : "statefulmacro", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/StatefulMacro", + "state" : { + "branch" : "main", + "revision" : "7f56ed3f2297ba86c126e33d40af8b1f908e3a53" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit", + "state" : { + "revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666", + "version" : "3.0.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths.git", + "state" : { + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "bbadd4b853a33fd78c4ae977d17bb2af15eb3f2a", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, + { + "identity" : "swizzleswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MarioIannotta/SwizzleSwift", + "state" : { + "branch" : "master", + "revision" : "e2d31c646182bf94a496b173c6ee5ad191230e9a" + } + }, + { + "identity" : "transmission", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nathantannar4/Transmission", + "state" : { + "revision" : "2da5049d33111238ad4cb979ebce783af9586cef", + "version" : "2.4.5" + } + }, + { + "identity" : "tvospicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ViacomInc/TVOSPicker", + "state" : { + "revision" : "90806460f3b3e7564344647241c157aeb0b27a71", + "version" : "0.3.0" + } + }, + { + "identity" : "udpbroadcastconnection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gunterhager/UDPBroadcastConnection", + "state" : { + "revision" : "3680f532c00d18a168bb8da7b9f8f82fcd89180d", + "version" : "5.0.5" + } + }, + { + "identity" : "urlqueryencoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CreateAPI/URLQueryEncoder", + "state" : { + "revision" : "4ce950479707ea109f229d7230ec074a133b15d7", + "version" : "0.2.1" + } + }, + { + "identity" : "vlcui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/VLCUI", + "state" : { + "revision" : "9850f460ce5f4154979d17aeae92674c1632ff53", + "version" : "0.7.4" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" + } + } + ], + "version" : 3 +} diff --git a/jellypig.xcodeproj/xcshareddata/IDETemplateMacros.plist b/Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist similarity index 100% rename from jellypig.xcodeproj/xcshareddata/IDETemplateMacros.plist rename to Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist diff --git a/jellypig.xcodeproj/xcshareddata/xcschemes/jellypig tvOS.xcscheme b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme similarity index 83% rename from jellypig.xcodeproj/xcshareddata/xcschemes/jellypig tvOS.xcscheme rename to Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme index f32d385f..7449a0c3 100644 --- a/jellypig.xcodeproj/xcshareddata/xcschemes/jellypig tvOS.xcscheme +++ b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "Swiftfin tvOS.app" + BlueprintName = "Swiftfin tvOS" + ReferencedContainer = "container:Swiftfin.xcodeproj"> @@ -45,9 +45,9 @@ + BuildableName = "Swiftfin tvOS.app" + BlueprintName = "Swiftfin tvOS" + ReferencedContainer = "container:Swiftfin.xcodeproj"> @@ -62,9 +62,9 @@ + BuildableName = "Swiftfin tvOS.app" + BlueprintName = "Swiftfin tvOS" + ReferencedContainer = "container:Swiftfin.xcodeproj"> diff --git a/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin.xcscheme b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin.xcscheme new file mode 100644 index 00000000..edaa97ae --- /dev/null +++ b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/App/AppDelegate.swift b/Swiftfin/App/AppDelegate.swift new file mode 100644 index 00000000..1977fcf0 --- /dev/null +++ b/Swiftfin/App/AppDelegate.swift @@ -0,0 +1,32 @@ +// +// 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 PreferencesView +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + true + } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let topViewController = scene.keyWindow?.rootViewController, + let presentedViewController = topViewController.presentedViewController, + let preferencesHostingController = presentedViewController as? UIPreferencesHostingController + { + return preferencesHostingController.supportedInterfaceOrientations + } + + return UIDevice.isPad ? .allButUpsideDown : .portrait + } +} diff --git a/Swiftfin/App/SwiftfinApp+ValueObservation.swift b/Swiftfin/App/SwiftfinApp+ValueObservation.swift new file mode 100644 index 00000000..a666fc6a --- /dev/null +++ b/Swiftfin/App/SwiftfinApp+ValueObservation.swift @@ -0,0 +1,124 @@ +// +// 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 Combine +import Defaults +import Factory +import Foundation +import SwiftUI + +// Following class is necessary to observe values that can either +// be a user *or* an app setting and only one should apply at a time. +// +// Also just to separate out value observation + +// TODO: could clean up? + +extension SwiftfinApp { + + class ValueObservation: ObservableObject { + + private var accentColorCancellable: AnyCancellable? + private var appearanceCancellable: AnyCancellable? + private var lastSignInUserIDCancellable: AnyCancellable? + private var splashScreenCancellable: AnyCancellable? + + init() { + + // MARK: signed in observation + + lastSignInUserIDCancellable = Task { + for await newValue in Defaults.updates(.lastSignedInUserID) { + + Container.shared.mediaPlayerManager.reset() + + if case .signedIn = newValue { + setUserDefaultsObservation() + } else { + setAppDefaultsObservation() + } + } + } + .asAnyCancellable() + } + + // MARK: user observation + + private func setUserDefaultsObservation() { + accentColorCancellable?.cancel() + appearanceCancellable?.cancel() + splashScreenCancellable?.cancel() + + accentColorCancellable = Task { + for await newValue in Defaults.updates(.userAccentColor) { + await MainActor.run { + Defaults[.accentColor] = newValue + UIApplication.shared.setAccentColor(newValue.uiColor) + } + } + } + .asAnyCancellable() + + appearanceCancellable = Task { + for await newValue in Defaults.updates(.userAppearance) { + await MainActor.run { + Defaults[.appearance] = newValue + UIApplication.shared.setAppearance(newValue.style) + } + } + } + .asAnyCancellable() + } + + // MARK: app observation + + private func setAppDefaultsObservation() { + accentColorCancellable?.cancel() + appearanceCancellable?.cancel() + splashScreenCancellable?.cancel() + + accentColorCancellable = Task { + await MainActor.run { + Defaults[.accentColor] = .jellyfinPurple + UIApplication.shared.setAccentColor(Color.jellyfinPurple.uiColor) + } + } + .asAnyCancellable() + + appearanceCancellable = Task { + for await newValue in Defaults.updates(.appAppearance) { + + // other cancellable will set appearance if enabled + // and need to avoid races + guard !Defaults[.selectUserUseSplashscreen] else { continue } + + await MainActor.run { + Defaults[.appearance] = newValue + UIApplication.shared.setAppearance(newValue.style) + } + } + } + .asAnyCancellable() + + splashScreenCancellable = Task { + for await newValue in Defaults.updates(.selectUserUseSplashscreen) { + await MainActor.run { + if newValue { + Defaults[.appearance] = .dark + UIApplication.shared.setAppearance(.dark) + } else { + Defaults[.appearance] = Defaults[.appAppearance] + UIApplication.shared.setAppearance(Defaults[.appAppearance].style) + } + } + } + } + .asAnyCancellable() + } + } +} diff --git a/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift new file mode 100644 index 00000000..7be62f7e --- /dev/null +++ b/Swiftfin/App/SwiftfinApp.swift @@ -0,0 +1,110 @@ +// +// 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 CoreStore +import Defaults +import Factory +import Logging +import Nuke +import PreferencesView +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate + + @StateObject + private var valueObservation = ValueObservation() + + init() { + + // Logging + LoggingSystem.bootstrap { label in + + // TODO: have setting for log level + // - default info, boolean to go down to trace + let handlers: [any LogHandler] = [PersistentLogHandler(label: label)] + #if DEBUG + .appending(SwiftfinConsoleHandler()) + #endif + + var multiplexHandler = MultiplexLogHandler(handlers) + multiplexHandler.logLevel = .trace + return multiplexHandler + } + + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.posters + + // UIKit + + UIScrollView.appearance().keyboardDismissMode = .onDrag + + // Sometimes the tab bar won't appear properly on push, always have material background + UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) + + // Swiftfin + + // don't keep last user id + if Defaults[.signOutOnClose] { + Defaults[.lastSignedInUserID] = .signedOut + } + } + + var body: some Scene { + WindowGroup { + OverlayToastView { + PreferencesView { + RootView() + .supportedOrientations(UIDevice.isPad ? .allButUpsideDown : .portrait) + } + } + .ignoresSafeArea() + .onAppDidEnterBackground { + Defaults[.backgroundTimeStamp] = Date.now + } + .onAppWillEnterForeground { + + // TODO: needs to check if any background playback is happening + // - atow, background video playback isn't officially supported + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = .signedOut + Container.shared.currentUserSession.reset() + Notifications[.didSignOut].post() + } + } + } + } +} + +extension UINavigationController { + + // Remove back button text + override open func viewWillLayoutSubviews() { + navigationBar.topItem?.backButtonDisplayMode = .minimal + } +} diff --git a/Swiftfin/Components/BasicStepper.swift b/Swiftfin/Components/BasicStepper.swift new file mode 100644 index 00000000..27ae80d3 --- /dev/null +++ b/Swiftfin/Components/BasicStepper.swift @@ -0,0 +1,70 @@ +// +// 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 BasicStepper: View where Formatter.FormatInput == Value, +Formatter.FormatOutput == String { + + @Binding + private var value: Value + + private let title: String + private let range: ClosedRange + private let step: Value.Stride + private let formatter: Formatter + + var body: some View { + Stepper(value: $value, in: range, step: step) { + HStack { + Text(title) + + Spacer() + + Text(value, format: formatter) + .foregroundColor(.secondary) + } + } + } +} + +extension BasicStepper { + + init( + _ title: String, + value: Binding, + range: ClosedRange, + step: Value.Stride = 1, + formatter: Formatter + ) { + self.init( + value: value, + title: title, + range: range, + step: step, + formatter: formatter + ) + } +} + +extension BasicStepper where Formatter == VerbatimFormatStyle { + init( + _ title: String, + value: Binding, + range: ClosedRange, + step: Value.Stride = 1 + ) { + self.init( + value: value, + title: title, + range: range, + step: step, + formatter: VerbatimFormatStyle() + ) + } +} diff --git a/Swiftfin/Components/DelayedProgressView.swift b/Swiftfin/Components/DelayedProgressView.swift new file mode 100644 index 00000000..a55e738f --- /dev/null +++ b/Swiftfin/Components/DelayedProgressView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import SwiftUI + +// TODO: retry button and/or loading text after a few more seconds +struct DelayedProgressView: View { + + @State + private var interval = 0 + + private let timer: Publishers.Autoconnect + + init(interval: Double = 0.5) { + self.timer = Timer.publish(every: interval, on: .main, in: .common).autoconnect() + } + + var body: some View { + VStack { + if interval > 0 { + ProgressView() + } + } + .onReceive(timer) { _ in + withAnimation { + interval += 1 + } + } + } +} diff --git a/Swiftfin/Components/DotHStack.swift b/Swiftfin/Components/DotHStack.swift new file mode 100644 index 00000000..6aed1842 --- /dev/null +++ b/Swiftfin/Components/DotHStack.swift @@ -0,0 +1,22 @@ +// +// 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 + +func DotHStack( + padding: CGFloat = 5, + @ViewBuilder content: @escaping () -> some View +) -> some View { + SeparatorHStack { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, padding) + } content: { + content() + } +} diff --git a/Swiftfin/Components/EmptyHitTestView.swift b/Swiftfin/Components/EmptyHitTestView.swift new file mode 100644 index 00000000..acc998c2 --- /dev/null +++ b/Swiftfin/Components/EmptyHitTestView.swift @@ -0,0 +1,19 @@ +// +// 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 + +/// An empty `UIView` for the general purpose of +/// being a hit target. +struct EmptyHitTestView: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + UIView() + } + + func updateUIView(_ uiView: UIView, context: Context) {} +} diff --git a/Swiftfin/Components/ErrorView.swift b/Swiftfin/Components/ErrorView.swift new file mode 100644 index 00000000..ba68f514 --- /dev/null +++ b/Swiftfin/Components/ErrorView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView: View { + + private let error: ErrorType + private var onRetry: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + if let onRetry { + PrimaryButton(title: L10n.retry) + .onSelect(onRetry) + .frame(maxWidth: 300) + .frame(height: 50) + } + } + } +} + +extension ErrorView { + + init(error: ErrorType) { + self.init( + error: error, + onRetry: nil + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/Swiftfin/Components/GestureView.swift b/Swiftfin/Components/GestureView.swift new file mode 100644 index 00000000..205a689f --- /dev/null +++ b/Swiftfin/Components/GestureView.swift @@ -0,0 +1,177 @@ +// +// 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 Combine +import Foundation +import Logging +import SwiftUI + +// TODO: figure out this directional response stuff +extension EnvironmentValues { + + @Entry + var panGestureDirection: Direction = .all +} + +struct GestureView: UIViewRepresentable { + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + + view.addGestureRecognizer(context.coordinator.longPressGesture) + view.addGestureRecognizer(context.coordinator.panGesture) + view.addGestureRecognizer(context.coordinator.pinchGesture) + view.addGestureRecognizer(context.coordinator.tapGesture) + view.addGestureRecognizer(context.coordinator.doubleTouchGesture) + + view.backgroundColor = .clear + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + + context.coordinator.longPressAction = context.environment.longPressAction + context.coordinator.panAction = context.environment.panAction + context.coordinator.pinchAction = context.environment.pinchAction + context.coordinator.tapAction = context.environment.tapGestureAction + + context.coordinator.panGesture.direction = context.environment.panGestureDirection + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + + lazy var doubleTouchGesture: UITapGestureRecognizer! = { + let recognizer = UITapGestureRecognizer( + target: self, + action: #selector(handleTap) + ) + recognizer.numberOfTouchesRequired = 2 + return recognizer + }() + + lazy var longPressGesture: UILongPressGestureRecognizer! = { + let recognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleLongPress) + ) + recognizer.minimumPressDuration = 1.2 + return recognizer + }() + + lazy var panGesture: DirectionalPanGestureRecognizer! = { + .init( + direction: .allButDown, + target: self, + action: #selector(handlePan) + ) + }() + + lazy var pinchGesture: UIPinchGestureRecognizer! = { + .init( + target: self, + action: #selector(handlePinch) + ) + }() + + lazy var tapGesture: UITapGestureRecognizer! = { + .init( + target: self, + action: #selector(handleTap) + ) + }() + + var longPressAction: LongPressAction? { + didSet { longPressGesture.isEnabled = longPressAction != nil } + } + + var panAction: PanAction? { + didSet { panGesture.isEnabled = panAction != nil } + } + + var pinchAction: PinchAction? { + didSet { pinchGesture.isEnabled = pinchAction != nil } + } + + var tapAction: TapAction? { + didSet { + doubleTouchGesture.isEnabled = tapAction != nil + tapGesture.isEnabled = tapAction != nil + } + } + + private var didSwipe = false + + @objc + func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard let view = gesture.view else { return } + + let location = gesture.location(in: view) + let unitPoint = UnitPoint( + x: location.x / view.bounds.width, + y: location.y / view.bounds.height + ) + + longPressAction?( + location: location, + unitPoint: unitPoint, + state: gesture.state + ) + } + + @objc + func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let view = gesture.view else { return } + + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + let location = gesture.location(in: view) + let unitPoint = UnitPoint( + x: location.x / view.bounds.width, + y: location.y / view.bounds.height + ) + + panAction?( + translation: translation, + velocity: velocity, + location: location, + unitPoint: unitPoint, + state: gesture.state + ) + } + + @objc + func handlePinch(_ gesture: UIPinchGestureRecognizer) { + pinchAction?( + scale: gesture.scale, + velocity: gesture.velocity, + state: gesture.state + ) + } + + @objc + func handleTap(_ gesture: UITapGestureRecognizer) { + guard let view = gesture.view else { return } + + let location = gesture.location(in: gesture.view) + let unitPoint = UnitPoint( + x: location.x / view.bounds.width, + y: location.y / view.bounds.height + ) + + tapAction?( + location: location, + unitPoint: unitPoint, + count: gesture.numberOfTouches + ) + } + } +} diff --git a/Swiftfin/Components/HourMinutePicker.swift b/Swiftfin/Components/HourMinutePicker.swift new file mode 100644 index 00000000..1ad1b7b6 --- /dev/null +++ b/Swiftfin/Components/HourMinutePicker.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct HourMinutePicker: UIViewRepresentable { + + let interval: Binding + + func makeUIView(context: Context) -> some UIView { + let picker = UIDatePicker(frame: .zero) + picker.translatesAutoresizingMaskIntoConstraints = false + picker.datePickerMode = .countDownTimer + picker.countDownDuration = interval.wrappedValue + + context.coordinator.add(picker: picker) + context.coordinator.interval = interval + + return picker + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + + var interval: Binding! + + func add(picker: UIDatePicker) { + picker.addTarget( + self, + action: #selector( + dateChanged + ), + for: .valueChanged + ) + } + + @objc + func dateChanged(_ picker: UIDatePicker) { + interval.wrappedValue = picker.countDownDuration + } + } +} diff --git a/Swiftfin/Components/LandscapePosterProgressBar.swift b/Swiftfin/Components/LandscapePosterProgressBar.swift new file mode 100644 index 00000000..bf26db93 --- /dev/null +++ b/Swiftfin/Components/LandscapePosterProgressBar.swift @@ -0,0 +1,99 @@ +// +// 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 + +// TODO: fix relative padding, or remove? +// TODO: gradient should grow/shrink with content, not relative to container + +struct LandscapePosterProgressBar: View { + + @Default(.accentColor) + private var accentColor + + // Scale padding depending on view width + @State + private var paddingScale: CGFloat = 1.0 + @State + private var width: CGFloat = 0 + + private let content: () -> Content + private let progress: Double + + var body: some View { + ZStack(alignment: .bottom) { + + Color.clear + + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 40) + + VStack(alignment: .leading, spacing: 3 * paddingScale) { + + content() + + ProgressBar(progress: progress) + .foregroundColor(accentColor) + .frame(height: 3) + } + .padding(.horizontal, 5 * paddingScale) + .padding(.bottom, 7 * paddingScale) + } + .onSizeChanged { newSize, _ in + width = newSize.width + } + } +} + +extension LandscapePosterProgressBar where Content == Text { + + init( + title: String, + progress: Double + ) { + self.init( + content: { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + }, + progress: progress + ) + } +} + +extension LandscapePosterProgressBar where Content == EmptyView { + + init(progress: Double) { + self.init( + content: { EmptyView() }, + progress: progress + ) + } +} + +extension LandscapePosterProgressBar { + + init( + progress: Double, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + content: content, + progress: progress + ) + } +} diff --git a/Swiftfin/Components/LearnMoreButton.swift b/Swiftfin/Components/LearnMoreButton.swift new file mode 100644 index 00000000..e342c214 --- /dev/null +++ b/Swiftfin/Components/LearnMoreButton.swift @@ -0,0 +1,65 @@ +// +// 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 LearnMoreButton: View { + + @State + private var isPresented: Bool = false + + private let title: String + private let content: AnyView + + // MARK: - Initializer + + init( + _ title: String, + @LabeledContentBuilder content: () -> AnyView + ) { + self.title = title + self.content = content() + } + + // MARK: - Body + + var body: some View { + Button(L10n.learnMore + "\u{2026}") { + isPresented = true + } + .foregroundStyle(Color.accentColor) + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(isPresented: $isPresented) { + learnMoreView + } + } + + // MARK: - Learn More View + + private var learnMoreView: some View { + NavigationStack { + ScrollView { + SeparatorVStack(alignment: .leading) { + Divider() + .padding(.vertical, 8) + } content: { + content + .labeledContentStyle(LearnMoreLabeledContentStyle()) + .foregroundStyle(Color.primary, Color.secondary) + } + .edgePadding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + isPresented = false + } + } + } +} diff --git a/Swiftfin/Components/LetterPickerBar/Components/LetterPickerButton.swift b/Swiftfin/Components/LetterPickerBar/Components/LetterPickerButton.swift new file mode 100644 index 00000000..7aff431a --- /dev/null +++ b/Swiftfin/Components/LetterPickerBar/Components/LetterPickerButton.swift @@ -0,0 +1,53 @@ +// +// 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 LetterPickerBar { + + struct LetterPickerButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isSelected) + private var isSelected + + private let letter: ItemLetter + private let size: CGFloat + private let viewModel: FilterViewModel + + init(letter: ItemLetter, size: CGFloat, viewModel: FilterViewModel) { + self.letter = letter + self.size = size + self.viewModel = viewModel + } + + var body: some View { + Button { + if viewModel.currentFilters.letter.contains(letter) { + viewModel.send(.update(.letter, [])) + } else { + viewModel.send(.update(.letter, [ItemLetter(stringLiteral: letter.value).asAnyItemFilter])) + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 5) + .frame(width: size, height: size) + .foregroundStyle(isSelected ? accentColor : Color.clear) + + Text(letter.value) + .font(.headline) + .foregroundStyle(isSelected ? accentColor.overlayColor : accentColor) + .frame(width: size, height: size, alignment: .center) + } + } + } + } +} diff --git a/Swiftfin/Components/LetterPickerBar/LetterPickerBar.swift b/Swiftfin/Components/LetterPickerBar/LetterPickerBar.swift new file mode 100644 index 00000000..e833f5c8 --- /dev/null +++ b/Swiftfin/Components/LetterPickerBar/LetterPickerBar.swift @@ -0,0 +1,44 @@ +// +// 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 + +struct LetterPickerBar: View { + + @ObservedObject + var viewModel: FilterViewModel + + // MARK: - Body + + @ViewBuilder + var body: some View { + VStack(spacing: 0) { + Spacer() + + ForEach(ItemLetter.allCases, id: \.hashValue) { filterLetter in + LetterPickerButton( + letter: filterLetter, + size: LetterPickerBar.size, + viewModel: viewModel + ) + .isSelected(viewModel.currentFilters.letter.contains(filterLetter)) + } + + Spacer() + } + .scrollIfLargerThanContainer() + .frame(width: LetterPickerBar.size, alignment: .center) + } + + // MARK: - Letter Button Size + + static var size: CGFloat { + UIFont.preferredFont(forTextStyle: .headline).lineHeight + } +} diff --git a/Swiftfin/Components/ListRow.swift b/Swiftfin/Components/ListRow.swift new file mode 100644 index 00000000..78ad6154 --- /dev/null +++ b/Swiftfin/Components/ListRow.swift @@ -0,0 +1,73 @@ +// +// 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 + +// TODO: come up with better name along with `ListRowButton` + +// Meant to be used when making a custom list without `List` or `Form` +struct ListRow: View { + + @State + private var contentSize: CGSize = .zero + + private let leading: Leading + private let content: Content + private var action: () -> Void + private var insets: EdgeInsets + private var isSeparatorVisible: Bool + + var body: some View { + ZStack(alignment: .bottomTrailing) { + + Button(action: action) { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { + + leading + + content + .frame(maxHeight: .infinity) + .trackingSize($contentSize) + } + .padding(insets) + } + .foregroundStyle(.primary, .secondary) + .contentShape(.contextMenuPreview, Rectangle()) + + Color.secondarySystemFill + .frame(width: contentSize.width, height: 1) + .padding(.trailing, insets.trailing) + .isVisible(isSeparatorVisible) + } + } +} + +extension ListRow { + + init( + insets: EdgeInsets = .zero, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + leading: leading(), + content: content(), + action: {}, + insets: insets, + isSeparatorVisible: true + ) + } + + func isSeparatorVisible(_ isVisible: Bool) -> Self { + copy(modifying: \.isSeparatorVisible, with: isVisible) + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.action, with: action) + } +} diff --git a/Swiftfin/Components/ListRowButton.swift b/Swiftfin/Components/ListRowButton.swift new file mode 100644 index 00000000..544742ea --- /dev/null +++ b/Swiftfin/Components/ListRowButton.swift @@ -0,0 +1,65 @@ +// +// 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 + +// TODO: come up with better name along with `ListRow` + +// Meant to be used within `List` or `Form` +struct ListRowButton: View { + + private let title: String + private let role: ButtonRole? + private let action: () -> Void + + init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { + self.title = title + self.role = role + self.action = action + } + + var body: some View { + Button(title, role: role, action: action) + .buttonStyle(ListRowButtonStyle()) + .listRowInsets(.zero) + } +} + +private struct ListRowButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) + private var isEnabled + + private func primaryStyle(configuration: Configuration) -> some ShapeStyle { + if configuration.role == .destructive || configuration.role == .cancel { + return AnyShapeStyle(Color.red) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + private func secondaryStyle(configuration: Configuration) -> some ShapeStyle { + if configuration.role == .destructive || configuration.role == .cancel { + return AnyShapeStyle(Color.red.opacity(0.2)) + } else { + return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray) + } + } + + func makeBody(configuration: Configuration) -> some View { + ZStack { + Rectangle() + .fill(secondaryStyle(configuration: configuration)) + + configuration.label + .foregroundStyle(primaryStyle(configuration: configuration)) + } + .opacity(configuration.isPressed ? 0.75 : 1) + .font(.body.weight(.bold)) + } +} diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift new file mode 100644 index 00000000..8fae6c08 --- /dev/null +++ b/Swiftfin/Components/ListTitleSection.swift @@ -0,0 +1,192 @@ +// +// 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 + +// TODO: image +// TODO: rename + +struct ListTitleSection: View { + + private let title: String + private let description: String? + private let onLearnMore: (() -> Void)? + + var body: some View { + Section { + VStack(alignment: .center, spacing: 10) { + + Text(title) + .font(.title3) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + + if let description { + Text(description) + .multilineTextAlignment(.center) + } + + if let onLearnMore { + Button(L10n.learnMore + "\u{2026}", action: onLearnMore) + } + } + .font(.subheadline) + .frame(maxWidth: .infinity) + } + } +} + +extension ListTitleSection { + + init( + _ title: String, + description: String? = nil + ) { + self.init( + title: title, + description: description, + onLearnMore: nil + ) + } + + init( + _ title: String, + description: String? = nil, + onLearnMore: @escaping () -> Void + ) { + self.init( + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} + +/// A view that mimics an inset grouped section, meant to be +/// used as a header for a `List` with `listStyle(.plain)`. +struct InsetGroupedListHeader: View { + + @Default(.accentColor) + private var accentColor + + private let content: () -> Content + private let title: Text? + private let description: Text? + private let onLearnMore: (() -> Void)? + + @ViewBuilder + private var header: some View { + Button { + onLearnMore?() + } label: { + VStack(alignment: .center, spacing: 10) { + + if let title { + title + .font(.title3) + .fontWeight(.semibold) + } + + if let description { + description + .multilineTextAlignment(.center) + } + + if onLearnMore != nil { + Text(L10n.learnMore + "\u{2026}") + .foregroundStyle(accentColor) + } + } + .font(.subheadline) + .frame(maxWidth: .infinity) + .padding(16) + } + .foregroundStyle(.primary, .secondary) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondarySystemBackground) + + SeparatorVStack { + RowDivider() + } content: { + if title != nil || description != nil { + header + } + + content() + .listRowSeparator(.hidden) + .padding(.init(vertical: 5, horizontal: 20)) + .listRowInsets(.init(vertical: 10, horizontal: 20)) + } + } + } +} + +extension InsetGroupedListHeader { + + init( + _ title: String? = nil, + description: String? = nil, + onLearnMore: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + content: content, + title: title == nil ? nil : Text(title!), + description: description == nil ? nil : Text(description!), + onLearnMore: onLearnMore + ) + } + + init( + title: Text, + description: Text? = nil, + onLearnMore: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + content: content, + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} + +extension InsetGroupedListHeader where Content == EmptyView { + + init( + _ title: String, + description: String? = nil, + onLearnMore: (() -> Void)? = nil + ) { + self.init( + content: { EmptyView() }, + title: Text(title), + description: description == nil ? nil : Text(description!), + onLearnMore: onLearnMore + ) + } + + init( + title: Text, + description: Text? = nil, + onLearnMore: (() -> Void)? = nil + ) { + self.init( + content: { EmptyView() }, + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} diff --git a/Swiftfin/Components/LocalUserAccessPolicyView.swift b/Swiftfin/Components/LocalUserAccessPolicyView.swift new file mode 100644 index 00000000..9786f4e9 --- /dev/null +++ b/Swiftfin/Components/LocalUserAccessPolicyView.swift @@ -0,0 +1,105 @@ +// +// 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 LocalUserAccessPolicyView: View { + + @Binding + private var pinHint: String + @Binding + private var accessPolicy: UserAccessPolicy + + @Router + private var router + + @State + private var listSize: CGSize = .zero + @State + private var updatePinHint: String + @State + private var updateSignInPolicy: UserAccessPolicy + + init( + pinHint: Binding, + accessPolicy: Binding + ) { + self._pinHint = pinHint + self._accessPolicy = accessPolicy + self._updatePinHint = State(initialValue: pinHint.wrappedValue) + self._updateSignInPolicy = State(initialValue: accessPolicy.wrappedValue) + } + + var body: some View { + List { + Section { + CaseIterablePicker(L10n.security, selection: $updateSignInPolicy) + } footer: { + VStack(alignment: .leading, spacing: 10) { + Text( + L10n.additionalSecurityAccessDescription + ) + + // frame necessary with bug within BulletedList + BulletedList { + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) + .fontWeight(.semibold) + + Text(L10n.requireDeviceAuthDescription) + } + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requirePin.displayTitle) + .fontWeight(.semibold) + + Text(L10n.requirePinDescription) + } + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.none.displayTitle) + .fontWeight(.semibold) + + Text(L10n.saveUserWithoutAuthDescription) + } + } + .frame(width: max(10, listSize.width - 50)) + } + } + + if accessPolicy == .requirePin { + Section { + TextField(L10n.hint, text: $updatePinHint) + } header: { + Text(L10n.hint) + } footer: { + Text(L10n.setPinHintDescription) + } + } + } + .animation(.linear, value: accessPolicy) + .navigationTitle(L10n.security) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .onChange(of: updatePinHint) { newValue in + let truncated = String(newValue.prefix(120)) + updatePinHint = truncated + pinHint = truncated + } + .onChange(of: updatePinHint) { newValue in + pinHint = newValue + } + .onChange(of: updateSignInPolicy) { newValue in + accessPolicy = newValue + } + .trackingSize($listSize) + } +} diff --git a/Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift b/Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift new file mode 100644 index 00000000..97fcf6c6 --- /dev/null +++ b/Swiftfin/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift @@ -0,0 +1,81 @@ +// +// 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 NavigationBarFilterDrawer { + + struct FilterDrawerButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isSelected) + private var isSelected + + private let systemName: String? + private let title: String + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack(spacing: 2) { + Group { + if let systemName = systemName { + Image(systemName: systemName) + } else { + Text(title) + } + } + .font(.footnote.weight(.semibold)) + + Image(systemName: "chevron.down") + .font(.caption) + } + .foregroundColor(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + Capsule() + .foregroundColor(isSelected ? accentColor : Color(UIColor.secondarySystemFill)) + .opacity(0.5) + } + .overlay { + Capsule() + .stroke(isSelected ? accentColor : Color(UIColor.secondarySystemFill), lineWidth: 1) + } + } + } + } +} + +extension NavigationBarFilterDrawer.FilterDrawerButton { + + init(title: String) { + self.init( + systemName: nil, + title: title, + onSelect: {} + ) + } + + init(systemName: String) { + self.init( + systemName: systemName, + title: "", + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift b/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift new file mode 100644 index 00000000..ac92971e --- /dev/null +++ b/Swiftfin/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift @@ -0,0 +1,72 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct NavigationBarFilterDrawer: View { + + struct Parameters { + let type: ItemFilterType + let viewModel: FilterViewModel + } + + @ObservedObject + private var viewModel: FilterViewModel + + private var filterTypes: [ItemFilterType] + private var onSelect: (Parameters) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if viewModel.currentFilters.hasFilters { + Menu { + Button(L10n.reset, role: .destructive) { + viewModel.send(.reset()) + } + } label: { + FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill") + .isSelected(true) + } + } + + ForEach(filterTypes, id: \.self) { type in + FilterDrawerButton( + title: type.displayTitle + ) + .onSelect { + onSelect(.init(type: type, viewModel: viewModel)) + } + .environment( + \.isSelected, + viewModel.isFilterSelected(type: type) + ) + } + } + .padding(.horizontal) + .padding(.vertical, 1) + } + } +} + +extension NavigationBarFilterDrawer { + + init(viewModel: FilterViewModel, types: [ItemFilterType]) { + self.init( + viewModel: viewModel, + filterTypes: types, + onSelect: { _ in } + ) + } + + func onSelect(_ action: @escaping (Parameters) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/OrderedSectionSelectorView.swift b/Swiftfin/Components/OrderedSectionSelectorView.swift new file mode 100644 index 00000000..6f0b4577 --- /dev/null +++ b/Swiftfin/Components/OrderedSectionSelectorView.swift @@ -0,0 +1,124 @@ +// +// 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 OrderedSectionSelectorView: View { + + @Environment(\.editMode) + private var editMode + + @StateObject + private var selection: BindingBox<[Element]> + + private var disabledSelection: [Element] { + sources.subtracting(selection.value) + } + + private var label: (Element) -> any View + private let sources: [Element] + + private func move(from source: IndexSet, to destination: Int) { + selection.value.move(fromOffsets: source, toOffset: destination) + } + + private func select(element: Element) { + + UIDevice.impact(.light) + + if selection.value.contains(element) { + selection.value.removeAll(where: { $0 == element }) + } else { + selection.value.append(element) + } + } + + private var isReordering: Bool { + editMode?.wrappedValue.isEditing ?? false + } + + var body: some View { + List { + Section(L10n.enabled) { + + if selection.value.isEmpty { + L10n.none.text + .foregroundStyle(.secondary) + } + + ForEach(selection.value, id: \.self) { element in + Button { + if !isReordering { + select(element: element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isReordering { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + } + + Section(L10n.disabled) { + + if disabledSelection.isEmpty { + L10n.none.text + .foregroundStyle(.secondary) + } + + ForEach(disabledSelection, id: \.self) { element in + Button { + if !isReordering { + select(element: element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isReordering { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + } + } + .animation(.linear(duration: 0.2), value: selection.value) + .toolbar { + EditButton() + } + } +} + +extension OrderedSectionSelectorView { + + init(selection: Binding<[Element]>, sources: [Element]) { + self._selection = StateObject(wrappedValue: BindingBox(source: selection)) + self.sources = sources + self.label = { Text($0.displayTitle).foregroundColor(.primary) } + } + + func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { + copy(modifying: \.label, with: content) + } +} diff --git a/Swiftfin/Components/OverlayToastView.swift b/Swiftfin/Components/OverlayToastView.swift new file mode 100644 index 00000000..baed7c1e --- /dev/null +++ b/Swiftfin/Components/OverlayToastView.swift @@ -0,0 +1,107 @@ +// +// 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 Combine +import SwiftUI +import Transmission + +// TODO: make enhanced toasting system +// - allow actions +// - multiple toasts +// - sizes, stacked +// TODO: symbol effects + +// TODO: fix rapid fire animations +// - have one that's presentation based, one just basic overlay? + +/// A basic toasting container view that will present +/// given toasts on top of the given content. +struct OverlayToastView: View { + + @StateObject + private var toastProxy: ToastProxy + + private let content: Content + + init( + @ViewBuilder content: () -> Content + ) { + self._toastProxy = StateObject(wrappedValue: .init()) + self.content = content() + } + + init( + proxy: ToastProxy, + @ViewBuilder content: () -> Content + ) { + self._toastProxy = StateObject(wrappedValue: proxy) + self.content = content() + } + + var body: some View { + content + .presentation( + transition: .toast( + edge: .top, + isInteractive: true, + preferredPresentationBackgroundColor: .clear + ), + isPresented: $toastProxy.isPresenting + ) { + OverlayToastContent() + .environmentObject(toastProxy) + } + .environmentObject(toastProxy) + } +} + +private struct OverlayToastContent: View { + + @Environment(\.presentationCoordinator) + private var presentationCoordinator + + @EnvironmentObject + private var proxy: ToastProxy + + var body: some View { + Button { + presentationCoordinator.dismiss() + } label: { + HStack { + if let systemName = proxy.systemName { + Image(systemName: systemName) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25, height: 25) + } + + proxy.title + .font(.body) + .fontWeight(.bold) + .monospacedDigit() + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .frame(minHeight: 50) + .background(BlurView()) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 6) + } + .buttonStyle(ToastButtonStyle()) + } + + struct ToastButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.interactiveSpring, value: configuration.isPressed) + } + } +} diff --git a/Swiftfin/Components/PillHStack.swift b/Swiftfin/Components/PillHStack.swift new file mode 100644 index 00000000..ce8707a7 --- /dev/null +++ b/Swiftfin/Components/PillHStack.swift @@ -0,0 +1,62 @@ +// +// 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 PillHStack: View { + + private var title: String + private var items: [Item] + private var onSelect: (Item) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .edgePadding(.leading) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.displayTitle) { item in + Button { + onSelect(item) + } label: { + Text(item.displayTitle) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(10) + .background { + Color.systemFill + .cornerRadius(10) + } + } + } + } + .edgePadding(.horizontal) + } + } + } +} + +extension PillHStack { + + init(title: String, items: [Item]) { + self.init( + title: title, + items: items, + onSelect: { _ in } + ) + } + + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift new file mode 100644 index 00000000..b5eeb80c --- /dev/null +++ b/Swiftfin/Components/PosterButton.swift @@ -0,0 +1,221 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: expose `ImageView.image` modifier for image aspect fill/fit + +struct PosterButton: View { + + @EnvironmentTypeValue(\.posterOverlayRegistry) + private var posterOverlayRegistry + + @Namespace + private var namespace + + @State + private var posterSize: CGSize = .zero + + private let item: Item + private let type: PosterDisplayType + private let label: any View + private let action: (Namespace.ID) -> Void + + @ViewBuilder + private func posterView(overlay: some View = EmptyView()) -> some View { + VStack(alignment: .leading) { + PosterImage(item: item, type: type) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { overlay } + .contentShape(.contextMenuPreview, Rectangle()) + .posterCornerRadius(type) + .backport + .matchedTransitionSource(id: "item", in: namespace) + .posterShadow() + + label + .eraseToAnyView() + .allowsHitTesting(false) + } + } + + var body: some View { + Button { + action(namespace) + } label: { + let overlay = posterOverlayRegistry?(item) ?? + PosterButton.DefaultOverlay(item: item) + .eraseToAnyView() + + posterView(overlay: overlay) + .trackingSize($posterSize) + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.plain) + .matchedContextMenu(for: item) { + let frameScale = 1.3 + + posterView() + .frame( + width: posterSize.width * frameScale, + height: posterSize.height * frameScale + ) + .padding(20) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(Color(uiColor: UIColor.secondarySystemGroupedBackground)) + } + } + } +} + +extension PosterButton { + + init( + item: Item, + type: PosterDisplayType, + action: @escaping (Namespace.ID) -> Void, + @ViewBuilder label: @escaping () -> any View + ) { + self.item = item + self.type = type + self.action = action + self.label = label() + } +} + +// TODO: remove these and replace with `TextStyle` + +extension PosterButton { + + // MARK: Default Content + + struct TitleContentView: View { + + let title: String + + var body: some View { + Text(title) + .font(.footnote) + .fontWeight(.regular) + .foregroundStyle(.primary) + } + } + + struct SubtitleContentView: View { + + let subtitle: String? + + var body: some View { + Text(subtitle ?? " ") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + } + } + + struct TitleSubtitleContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if item.showTitle { + TitleContentView(title: item.displayTitle) + .lineLimit(1, reservesSpace: true) + } + + SubtitleContentView(subtitle: item.subtitle) + .lineLimit(1, reservesSpace: true) + } + } + } + + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + } + + DotHStack(padding: 3) { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle || useSeriesLandscapeBackdrop { + Text(item.displayTitle) + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + + // MARK: Default Overlay + + struct DefaultOverlay: View { + + @Default(.accentColor) + private var accentColor + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let item: Item + + var body: some View { + ZStack { + if let item = item as? BaseItemDto { + if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { + WatchedIndicator(size: 25) + .isVisible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 5) + .isVisible(showProgress) + } else if item.canBePlayed, !item.isLiveStream { + UnwatchedIndicator(size: 25) + .foregroundColor(accentColor) + .isVisible(showUnplayed) + } + } + + if item.userData?.isFavorite == true { + FavoriteIndicator(size: 25) + .isVisible(showFavorited) + } + } + } + } + } +} diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift new file mode 100644 index 00000000..7d9ba6b0 --- /dev/null +++ b/Swiftfin/Components/PosterHStack.swift @@ -0,0 +1,122 @@ +// +// 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 CollectionHStack +import SwiftUI + +// TODO: Migrate to single `header: View` + +struct PosterHStack: View where Data.Element == Element, Data.Index == Int { + + private var data: Data + private var header: () -> any View + private var title: String? + private var type: PosterDisplayType + private var label: (Element) -> any View + private var trailingContent: () -> any View + private var action: (Element, Namespace.ID) -> Void + + private var layout: CollectionHStackLayout { + if UIDevice.isPhone { + return .grid( + columns: type == .landscape ? 2 : 3, + rows: 1, + columnTrailingInset: 0 + ) + } else { + return .minimumWidth( + columnWidth: type == .landscape ? 220 : 140, + rows: 1 + ) + } + } + + @ViewBuilder + private var stack: some View { + CollectionHStack( + uniqueElements: data, + layout: layout + ) { item in + PosterButton( + item: item, + type: type + ) { namespace in + action(item, namespace) + } label: { + label(item).eraseToAnyView() + } + } + .clipsToBounds(false) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollBehavior(.continuousLeadingEdge) + } + + var body: some View { + VStack(alignment: .leading) { + + HStack { + header() + .eraseToAnyView() + + Spacer() + + trailingContent() + .eraseToAnyView() + } + .edgePadding(.horizontal) + + stack + } + } +} + +extension PosterHStack { + + init( + title: String? = nil, + type: PosterDisplayType, + items: Data, + action: @escaping (Element, Namespace.ID) -> Void, + @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } + ) { + self.init( + data: items, + header: { DefaultHeader(title: title) }, + title: title, + type: type, + label: label, + trailingContent: { EmptyView() }, + action: action + ) + } + + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} + +// MARK: Default Header + +extension PosterHStack { + + struct DefaultHeader: View { + + let title: String? + + var body: some View { + if let title { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + } + } + } +} diff --git a/Swiftfin/Components/PrimaryButton.swift b/Swiftfin/Components/PrimaryButton.swift new file mode 100644 index 00000000..87cee22a --- /dev/null +++ b/Swiftfin/Components/PrimaryButton.swift @@ -0,0 +1,51 @@ +// +// 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 + +struct PrimaryButton: View { + + @Default(.accentColor) + private var accentColor + + private let title: String + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + ZStack { + Rectangle() + .foregroundColor(accentColor) + .frame(maxWidth: 400) + .frame(height: 50) + .cornerRadius(10) + + Text(title) + .fontWeight(.bold) + .foregroundColor(accentColor.overlayColor) + } + } + } +} + +extension PrimaryButton { + + init(title: String) { + self.init( + title: title, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/SeeAllButton.swift b/Swiftfin/Components/SeeAllButton.swift new file mode 100644 index 00000000..5ea652b1 --- /dev/null +++ b/Swiftfin/Components/SeeAllButton.swift @@ -0,0 +1,37 @@ +// +// 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 SeeAllButton: View { + + private var action: () -> Void + + var body: some View { + Button( + L10n.seeAll, + systemImage: "chevron.right", + action: action + ) + .font(.subheadline.weight(.bold)) + .labelStyle(.titleAndIcon.trailingIcon) + } +} + +extension SeeAllButton { + + init() { + self.init( + action: {} + ) + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.action, with: action) + } +} diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift new file mode 100644 index 00000000..45b81164 --- /dev/null +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Factory +import SwiftUI + +struct SettingsBarButton: View { + + let server: ServerState + let user: UserState + let action: () -> Void + + var body: some View { + Button(action: action) { + AlternateLayoutView { + // Seems necessary for button layout + Image(systemName: "gearshape.fill") + } content: { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) + } + } + .accessibilityLabel(L10n.settings) + } +} diff --git a/Swiftfin/Components/Slider/CapsuleSlider.swift b/Swiftfin/Components/Slider/CapsuleSlider.swift new file mode 100644 index 00000000..3cc65ef7 --- /dev/null +++ b/Swiftfin/Components/Slider/CapsuleSlider.swift @@ -0,0 +1,150 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: change "damping" behavior +// - change to be based on given stride of `Value` +// to translation diff step + +struct CapsuleSlider: View { + + @Binding + private var value: Value + + @State + private var contentSize: CGSize = .zero + @State + private var gestureTranslation: CGPoint = .zero + @State + private var isEditing: Bool = false + @State + private var translationStartLocation: CGPoint = .zero + + @State + private var currentValueDampingStartTranslation: CGPoint = .zero + @State + private var currentValueDamping: Double = 1.0 + @State + private var currentValueDampingStartValue: Value = .zero + + @State + private var needsToSetTranslationStartState: Bool = true + + private var gesturePadding: CGFloat + private var onEditingChanged: (Bool) -> Void + private let total: Value + private let translationBinding: Binding + private let valueDamping: Double + + private var dragGesture: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { newValue in + if needsToSetTranslationStartState { + translationStartLocation = newValue.location + needsToSetTranslationStartState = false + + currentValueDamping = valueDamping + currentValueDampingStartTranslation = newValue.location + currentValueDampingStartValue = value + } + + if valueDamping != currentValueDamping { + currentValueDamping = valueDamping + currentValueDampingStartTranslation = newValue.location + currentValueDampingStartValue = value + } + + gestureTranslation = CGPoint( + x: translationStartLocation.x - newValue.location.x, + y: translationStartLocation.y - newValue.location.y + ) + + let newTranslation = CGPoint( + x: (currentValueDampingStartTranslation.x - newValue.location.x) * currentValueDamping, + y: currentValueDampingStartTranslation.y - newValue.location.y + ) + + let newProgress = currentValueDampingStartValue - Value(newTranslation.x / contentSize.width) * total + value = clamp(newProgress, min: 0, max: total) + } + } + + var body: some View { + ProgressView(value: value, total: total) + .progressViewStyle(.playback) + .overlay { + Color.clear + .frame(height: contentSize.height + gesturePadding) + .contentShape(Rectangle()) + .highPriorityGesture(dragGesture) + .onLongPressGesture(minimumDuration: 0.01, perform: {}) { isPressing in + if isPressing { + isEditing = true + onEditingChanged(true) + needsToSetTranslationStartState = true + } else { + translationBinding.wrappedValue = .zero + isEditing = false + onEditingChanged(false) + } + } + } + .trackingSize($contentSize) + .onChange(of: value) { newValue in + guard isEditing else { return } + + if newValue == 0 || newValue == total { + UIDevice.impact(.light) + } + } + .onChange(of: gestureTranslation) { newValue in + if isEditing { + translationBinding.wrappedValue = newValue + } + } + } +} + +extension CapsuleSlider { + + init( + value: Binding, + total: Value = 1.0, + valueDamping: Double = 1.0 + ) { + self.init( + value: value, + total: total, + translation: .constant(.zero), + valueDamping: valueDamping + ) + } + + init( + value: Binding, + total: Value = 1.0, + translation: Binding, + valueDamping: Double = 1.0 + ) { + self._value = value + self.gesturePadding = 0 + self.onEditingChanged = { _ in } + self.total = total + self.translationBinding = translation + self.valueDamping = clamp(valueDamping, min: 0.01, max: 2) + } + + func onEditingChanged(perform action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } + + func gesturePadding(_ padding: CGFloat) -> Self { + copy(modifying: \.gesturePadding, with: padding) + } +} diff --git a/Swiftfin/Components/Slider/ThumbSlider.swift b/Swiftfin/Components/Slider/ThumbSlider.swift new file mode 100644 index 00000000..3c00bd13 --- /dev/null +++ b/Swiftfin/Components/Slider/ThumbSlider.swift @@ -0,0 +1,103 @@ +// +// 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 + +// TODO: gesture padding + +struct ThumbSlider: View { + + @Binding + private var value: V + + @State + private var contentSize: CGSize = .zero + @State + private var isEditing: Bool = false + @State + private var translationStartLocation: CGPoint = .zero + @State + private var translationStartValue: V = 0 + @State + private var currentTranslation: CGFloat = 0 + + private var onEditingChanged: (Bool) -> Void + private let total: V + private var trackMask: () -> any View + + private var trackDrag: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { newValue in + if !isEditing { + isEditing = true + onEditingChanged(true) + translationStartValue = value + translationStartLocation = newValue.location + currentTranslation = 0 + } + + currentTranslation = translationStartLocation.x - newValue.location.x + + let newProgress = translationStartValue - V(currentTranslation / contentSize.width) * total + value = clamp(newProgress, min: 0, max: total) + } + .onEnded { _ in + isEditing = false + onEditingChanged(false) + } + } + + var body: some View { + ProgressView(value: value, total: total) + .progressViewStyle(.playback.square) + .overlay(alignment: .leading) { + Circle() + .foregroundStyle(.primary) + .frame(height: 20) + .gesture(trackDrag) + .offset(x: Double(value / total) * contentSize.width - 10) + } + .trackingSize($contentSize) + } +} + +extension ThumbSlider { + + init(value: Binding, total: V = 1.0) { + self.init( + value: value, + onEditingChanged: { _ in }, + total: total, + trackMask: { Color.white } + ) + } + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } + + func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trackMask, with: content) + } +} + +struct ThumbSliderTests: View { + + @State + private var value: Double = 0.3 + + var body: some View { + ThumbSlider(value: $value, total: 1.0) + .frame(height: 5) + .padding(.horizontal, 10) + } +} + +#Preview { + ThumbSliderTests() +} diff --git a/Swiftfin/Components/UnmaskSecureField.swift b/Swiftfin/Components/UnmaskSecureField.swift new file mode 100644 index 00000000..46c21b63 --- /dev/null +++ b/Swiftfin/Components/UnmaskSecureField.swift @@ -0,0 +1,146 @@ +// +// 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 + +// TODO: use _UIHostingView for button animation workaround? +// - have a nice animation for toggle + +/// - Note: Do not use this view directly. +/// Use `SecureField.init(_:text:maskToggle)` instead +struct _UnmaskSecureField: UIViewRepresentable { + + private var submitAction: () -> Void + private let text: Binding + private let title: String + + init( + _ title: String, + text: Binding + ) { + self.text = text + self.title = title + self.submitAction = {} + } + + func makeUIView(context: Context) -> UITextField { + + let textField = UITextField() + textField.font = context.environment.font?.uiFont ?? UIFont.preferredFont(forTextStyle: .body) + textField.adjustsFontForContentSizeCategory = true + textField.isSecureTextEntry = true + textField.keyboardType = .asciiCapable + textField.placeholder = title + textField.text = text.wrappedValue + textField.addTarget( + context.coordinator, + action: #selector(Coordinator.textDidChange), + for: .editingChanged + ) + + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget( + context.coordinator, + action: #selector(Coordinator.buttonPressed), + for: .touchUpInside + ) + button.setImage( + UIImage(systemName: "eye.fill"), + for: .normal + ) + + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 50), + button.widthAnchor.constraint(equalToConstant: 50), + ]) + + textField.rightView = button + textField.rightViewMode = .always + + context.coordinator.button = button + context.coordinator.submitAction = submitAction + context.coordinator.textField = textField + context.coordinator.textDidChange() + context.coordinator.textBinding = text + + textField.delegate = context.coordinator + + return textField + } + + func updateUIView(_ textField: UITextField, context: Context) { + if text.wrappedValue != textField.text { + textField.text = text.wrappedValue + } + + context.coordinator.submitAction = submitAction + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, UITextFieldDelegate { + + weak var button: UIButton? + weak var textField: UITextField? + var textBinding: Binding = .constant("") + var submitAction: () -> Void = {} + + @objc + func buttonPressed() { + guard let textField else { return } + textField.toggleSecureEntry() + + let eye = textField.isSecureTextEntry ? "eye.fill" : "eye.slash" + button?.setImage(UIImage(systemName: eye), for: .normal) + } + + @objc + func textDidChange() { + guard let textField, let text = textField.text else { return } + button?.isEnabled = !text.isEmpty + textBinding.wrappedValue = text + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + submitAction() + return true + } + } +} + +extension _UnmaskSecureField { + + func onSubmit(_ action: @escaping () -> Void) -> some View { + copy(modifying: \.submitAction, with: action) + } +} + +private extension UITextField { + + // https://stackoverflow.com/a/48115361 + func toggleSecureEntry() { + + isSecureTextEntry.toggle() + + if let existingText = text, isSecureTextEntry { + deleteBackward() + + if let textRange = textRange(from: beginningOfDocument, to: endOfDocument) { + replace(textRange, withText: existingText) + } + } + + if let existingSelectedTextRange = selectedTextRange { + selectedTextRange = nil + selectedTextRange = existingSelectedTextRange + } + } +} diff --git a/Swiftfin/Components/Video3DFormatPicker.swift b/Swiftfin/Components/Video3DFormatPicker.swift new file mode 100644 index 00000000..f5916cde --- /dev/null +++ b/Swiftfin/Components/Video3DFormatPicker.swift @@ -0,0 +1,25 @@ +// +// 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 + +struct Video3DFormatPicker: View { + let title: String + @Binding + var selectedFormat: Video3DFormat? + + var body: some View { + Picker(title, selection: $selectedFormat) { + Text(L10n.none).tag(nil as Video3DFormat?) + ForEach(Video3DFormat.allCases, id: \.self) { format in + Text(format.displayTitle).tag(format as Video3DFormat?) + } + } + } +} diff --git a/Swiftfin/Extensions/ButtonStyle-iOS.swift b/Swiftfin/Extensions/ButtonStyle-iOS.swift new file mode 100644 index 00000000..288052e7 --- /dev/null +++ b/Swiftfin/Extensions/ButtonStyle-iOS.swift @@ -0,0 +1,124 @@ +// +// 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 + +/// - Important: On iOS, this is a `BorderlessButtonStyle` instead. +/// This is only used to allow platform shared views. +extension PrimitiveButtonStyle where Self == BorderlessButtonStyle { + static var card: BorderlessButtonStyle { .init() } +} + +extension ButtonStyle where Self == ToolbarPillButtonStyle { + + static var toolbarPill: ToolbarPillButtonStyle { + ToolbarPillButtonStyle(primary: Defaults[.accentColor], secondary: .secondary) + } + + static func toolbarPill(_ primary: Color, _ secondary: Color = Color.secondary) -> ToolbarPillButtonStyle { + ToolbarPillButtonStyle(primary: primary, secondary: secondary) + } +} + +// TODO: don't take `Color`, take generic `ShapeStyle` +struct ToolbarPillButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) + private var isEnabled + + let primary: Color + let secondary: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(isEnabled ? primary.overlayColor : secondary) + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background(isEnabled ? primary : secondary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .opacity(isEnabled && !configuration.isPressed ? 1 : 0.5) + } +} + +extension ButtonStyle where Self == TintedMaterialButtonStyle { + + // TODO: just be `Material` backed instead of `TintedMaterial` + static var material: TintedMaterialButtonStyle { + TintedMaterialButtonStyle(tint: Color.clear, foregroundColor: Color.primary) + } + + static func tintedMaterial(tint: Color, foregroundColor: Color) -> TintedMaterialButtonStyle { + TintedMaterialButtonStyle( + tint: tint, + foregroundColor: foregroundColor + ) + } +} + +struct TintedMaterialButtonStyle: ButtonStyle { + + @Environment(\.isSelected) + private var isSelected + @Environment(\.isEnabled) + private var isEnabled + + // Take tint instead of reading from view as + // global accent color causes flashes of color + let tint: Color + let foregroundColor: Color + + func makeBody(configuration: Configuration) -> some View { + ZStack { + // TODO: use container relative shape instead of corner radius + TintedMaterial(tint: buttonTint) + .cornerRadius(10) + .id(isSelected) + + configuration.label + .foregroundStyle(foregroundStyle) + .symbolRenderingMode(.monochrome) + } + } + + private var buttonTint: Color { + if isEnabled && isSelected { + tint + } else { + Color.gray.opacity(0.3) + } + } + + private var foregroundStyle: AnyShapeStyle { + if isSelected { + AnyShapeStyle(foregroundColor) + } else if isEnabled { + AnyShapeStyle(HierarchicalShapeStyle.primary) + } else { + AnyShapeStyle(Color.gray.opacity(0.3)) + } + } +} + +extension ButtonStyle where Self == IsPressedButtonStyle { + + static func isPressed(_ isPressed: @escaping (Bool) -> Void) -> IsPressedButtonStyle { + IsPressedButtonStyle(isPressed: isPressed) + } +} + +struct IsPressedButtonStyle: ButtonStyle { + + let isPressed: (Bool) -> Void + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .onChange(of: configuration.isPressed, perform: isPressed) + } +} diff --git a/Swiftfin/Extensions/LAContext.swift b/Swiftfin/Extensions/LAContext.swift new file mode 100644 index 00000000..b4207be6 --- /dev/null +++ b/Swiftfin/Extensions/LAContext.swift @@ -0,0 +1,22 @@ +// +// 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 Foundation +import LocalAuthentication + +extension LAContext { + + func canEvaluatePolicy(_ policy: LAPolicy) throws { + var error: NSError? + _ = canEvaluatePolicy(policy, error: &error) + + if let error { + throw error + } + } +} diff --git a/Swiftfin/Extensions/Label-iOS.swift b/Swiftfin/Extensions/Label-iOS.swift new file mode 100644 index 00000000..b156c1cf --- /dev/null +++ b/Swiftfin/Extensions/Label-iOS.swift @@ -0,0 +1,61 @@ +// +// 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 + +// TODO: see if could be moved to `Shared` + +// MARK: EpisodeSelectorLabelStyle + +extension LabelStyle where Self == EpisodeSelectorLabelStyle { + + static var episodeSelector: EpisodeSelectorLabelStyle { + EpisodeSelectorLabelStyle() + } +} + +struct EpisodeSelectorLabelStyle: LabelStyle { + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + + configuration.icon + } + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + Color.tertiarySystemFill + .cornerRadius(10) + } + .compositingGroup() + .shadow(radius: 1) + .font(.caption) + } +} + +// MARK: SectionFooterWithImageLabelStyle + +extension TitleAndIconLabelStyle { + + var trailingIcon: TrailingIconReversedButtonStyle { + TrailingIconReversedButtonStyle() + } +} + +struct TrailingIconReversedButtonStyle: LabelStyle { + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + + configuration.icon + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift b/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift new file mode 100644 index 00000000..fb24906b --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift @@ -0,0 +1,22 @@ +// +// 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 DetectOrientation: ViewModifier { + + @Binding + var orientation: UIDeviceOrientation + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + orientation = UIDevice.current.orientation + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift new file mode 100644 index 00000000..ddc9d9fd --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct NavigationBarCloseButtonModifier: ViewModifier { + + @Default(.accentColor) + private var accentColor + + let disabled: Bool + let action: () -> Void + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button { + action() + } label: { + Image(systemName: "xmark.circle.fill") + .fontWeight(.bold) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + .opacity(disabled ? 0.75 : 1) + } + .disabled(disabled) + } + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift new file mode 100644 index 00000000..c6b363bf --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift @@ -0,0 +1,28 @@ +// +// 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 NavigationBarDrawerModifier: ViewModifier { + + private let drawer: () -> Drawer + + init(@ViewBuilder drawer: @escaping () -> Drawer) { + self.drawer = drawer + } + + func body(content: Content) -> some View { + NavigationBarDrawerView { + drawer() + .ignoresSafeArea() + } content: { + content + } + .ignoresSafeArea() + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift new file mode 100644 index 00000000..fe58581b --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift @@ -0,0 +1,113 @@ +// +// 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 NavigationBarDrawerView: UIViewControllerRepresentable { + + private let buttons: () -> Drawer + private let content: () -> Content + + init( + @ViewBuilder buttons: @escaping () -> Drawer, + @ViewBuilder content: @escaping () -> Content + ) { + self.buttons = buttons + self.content = content + } + + func makeUIViewController(context: Context) -> UINavigationBarDrawerHostingController { + UINavigationBarDrawerHostingController(buttons: buttons, content: content) + } + + func updateUIViewController(_ uiViewController: UINavigationBarDrawerHostingController, context: Context) {} +} + +class UINavigationBarDrawerHostingController: UIHostingController { + + private let drawer: () -> Drawer + private let content: () -> Content + + // TODO: see if we can get the height instead from the view passed in + private let drawerHeight: CGFloat = 36 + + private lazy var blurView: UIVisualEffectView = { + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) + blurView.translatesAutoresizingMaskIntoConstraints = false + return blurView + }() + + private lazy var drawerButtonsView: UIHostingController = { + let drawerButtonsView = UIHostingController(rootView: drawer()) + drawerButtonsView.view.translatesAutoresizingMaskIntoConstraints = false + drawerButtonsView.view.backgroundColor = nil + return drawerButtonsView + }() + + init( + buttons: @escaping () -> Drawer, + content: @escaping () -> Content + ) { + self.drawer = buttons + self.content = content + + super.init(rootView: content()) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = nil + + view.addSubview(blurView) + + addChild(drawerButtonsView) + view.addSubview(drawerButtonsView.view) + drawerButtonsView.didMove(toParent: self) + + NSLayoutConstraint.activate([ + drawerButtonsView.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: -drawerHeight), + drawerButtonsView.view.heightAnchor.constraint(equalToConstant: drawerHeight), + drawerButtonsView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + drawerButtonsView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + NSLayoutConstraint.activate([ + blurView.topAnchor.constraint(equalTo: view.topAnchor), + blurView.bottomAnchor.constraint(equalTo: drawerButtonsView.view.bottomAnchor), + blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationController?.navigationBar.shadowImage = UIImage() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.navigationBar.setBackgroundImage(nil, for: .default) + navigationController?.navigationBar.shadowImage = nil + } + + override var additionalSafeAreaInsets: UIEdgeInsets { + get { + .init(top: drawerHeight, left: 0, bottom: 0, right: 0) + } + set { + super.additionalSafeAreaInsets = .init(top: drawerHeight, left: 0, bottom: 0, right: 0) + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift new file mode 100644 index 00000000..cc6eb6c3 --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift @@ -0,0 +1,40 @@ +// +// 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 + +struct NavigationBarMenuButtonModifier: ViewModifier { + + @Default(.accentColor) + private var accentColor + + let isLoading: Bool + let isHidden: Bool + let items: () -> Content + + func body(content: Self.Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + + if isLoading { + ProgressView() + } + + if !isHidden { + Menu(L10n.options, systemImage: "ellipsis.circle") { + items() + } + .labelStyle(.iconOnly) + .fontWeight(.semibold) + .foregroundStyle(accentColor) + } + } + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift new file mode 100644 index 00000000..2c5469c6 --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift @@ -0,0 +1,29 @@ +// +// 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 NavigationBarOffsetModifier: ViewModifier { + + @Binding + var scrollViewOffset: CGFloat + + let start: CGFloat + let end: CGFloat + + func body(content: Content) -> some View { + NavigationBarOffsetView( + scrollViewOffset: $scrollViewOffset, + start: start, + end: end + ) { + content + } + .ignoresSafeArea() + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift new file mode 100644 index 00000000..5d22d1d1 --- /dev/null +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift @@ -0,0 +1,97 @@ +// +// 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 + +// TODO: fix lifecycle with zoom transition + +struct NavigationBarOffsetView: UIViewControllerRepresentable { + + @Binding + private var scrollViewOffset: CGFloat + + private let start: CGFloat + private let end: CGFloat + private let content: () -> Content + + init( + scrollViewOffset: Binding, + start: CGFloat, + end: CGFloat, + @ViewBuilder content: @escaping () -> Content + ) { + self._scrollViewOffset = scrollViewOffset + self.start = start + self.end = end + self.content = content + } + + func makeUIViewController(context: Context) -> UINavigationBarOffsetHostingController { + UINavigationBarOffsetHostingController(rootView: content()) + } + + func updateUIViewController(_ uiViewController: UINavigationBarOffsetHostingController, context: Context) { + uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) + } +} + +class UINavigationBarOffsetHostingController: UIHostingController { + + private var lastAlpha: CGFloat = 0 + + private lazy var blurView: UIVisualEffectView = { + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) + blurView.translatesAutoresizingMaskIntoConstraints = false + return blurView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = nil + + view.addSubview(blurView) + blurView.alpha = 0 + + NSLayoutConstraint.activate([ + blurView.topAnchor.constraint(equalTo: view.topAnchor), + blurView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } + + func scrollViewDidScroll(_ offset: CGFloat, start: CGFloat, end: CGFloat) { + + let diff = end - start + let currentProgress = (offset - start) / diff + let alpha = clamp(currentProgress, min: 0, max: 1) + + navigationController?.navigationBar + .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(alpha)] + blurView.alpha = alpha + lastAlpha = alpha + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationController?.navigationBar + .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(lastAlpha)] + navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationController?.navigationBar.shadowImage = UIImage() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label] + navigationController?.navigationBar.setBackgroundImage(nil, for: .default) + navigationController?.navigationBar.shadowImage = nil + } +} diff --git a/Swiftfin/Extensions/View/View-iOS.swift b/Swiftfin/Extensions/View/View-iOS.swift new file mode 100644 index 00000000..4a61aa56 --- /dev/null +++ b/Swiftfin/Extensions/View/View-iOS.swift @@ -0,0 +1,81 @@ +// +// 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 +import SwiftUIIntrospect + +extension View { + + func detectOrientation(_ orientation: Binding) -> some View { + modifier(DetectOrientation(orientation: orientation)) + } + + func navigationBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { + modifier(NavigationBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) + } + + func navigationBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { + modifier(NavigationBarDrawerModifier(drawer: drawer)) + } + + @ViewBuilder + func navigationBarFilterDrawer( + viewModel: FilterViewModel, + types: [ItemFilterType], + onSelect: @escaping (NavigationBarFilterDrawer.Parameters) -> Void + ) -> some View { + if types.isEmpty { + self + } else { + navigationBarDrawer { + NavigationBarFilterDrawer( + viewModel: viewModel, + types: types + ) + .onSelect(onSelect) + } + } + } + + @ViewBuilder + func navigationBarCloseButton( + disabled: Bool = false, + _ action: @escaping () -> Void + ) -> some View { + modifier( + NavigationBarCloseButtonModifier( + disabled: disabled, + action: action + ) + ) + } + + @ViewBuilder + func navigationBarMenuButton( + isLoading: Bool = false, + isHidden: Bool = false, + @ViewBuilder + _ items: @escaping () -> Content + ) -> some View { + modifier( + NavigationBarMenuButtonModifier( + isLoading: isLoading, + isHidden: isHidden, + items: items + ) + ) + } + + @ViewBuilder + func listRowCornerRadius(_ radius: CGFloat) -> some View { + introspect(.listCell, on: .iOS(.v16), .iOS(.v17), .iOS(.v18)) { cell in + cell.layer.cornerRadius = radius + } + } +} diff --git a/Swiftfin/Objects/AppURLHandler.swift b/Swiftfin/Objects/AppURLHandler.swift new file mode 100644 index 00000000..5c64f0ca --- /dev/null +++ b/Swiftfin/Objects/AppURLHandler.swift @@ -0,0 +1,109 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI + +final class AppURLHandler { + static let deepLinkScheme = "jellyfin" + + enum AppURLState { + case launched + case allowedInLogin + case allowed + + func allowedScheme(with url: URL) -> Bool { + switch self { + case .launched: + return false + case .allowed: + return true + case .allowedInLogin: + return false + } + } + } + + static let shared = AppURLHandler() + + var cancellables = Set() + + var appURLState: AppURLState = .launched + var launchURL: URL? +} + +extension AppURLHandler { + @discardableResult + func processDeepLink(url: URL) -> Bool { + guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { + return false + } + if AppURLHandler.shared.appURLState.allowedScheme(with: url) { + return processURL(url) + } else { + launchURL = url + } + return true + } + + func processLaunchedURLIfNeeded() { + guard let launchURL = launchURL, + launchURL.absoluteString.isNotEmpty else { return } + if processDeepLink(url: launchURL) { + self.launchURL = nil + } + } + + private func processURL(_ url: URL) -> Bool { + if processURLForUser(url: url) { + return true + } + + return false + } + + private func processURLForUser(url: URL) -> Bool { + guard url.host?.lowercased() == "users", + url.pathComponents[safe: 1]?.isEmpty == false else { return false } + + // /Users/{UserID}/Items/{ItemID} + if url.pathComponents[safe: 2]?.lowercased() == "items", + let userID = url.pathComponents[safe: 1], + let itemID = url.pathComponents[safe: 3] + { + // It would be nice if the ItemViewModel could be initialized to id later. + getItem(userID: userID, itemID: itemID) { item in + guard let item = item else { return } + // TODO: reimplement URL handling +// Notifications[.processDeepLink].post(DeepLink.item(item)) + } + + return true + } + + return false + } +} + +extension AppURLHandler { + func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { +// UserLibraryAPI.getItem(userId: userID, itemId: itemID) +// .sink(receiveCompletion: { innerCompletion in +// switch innerCompletion { +// case .failure: +// completion(nil) +// default: +// break +// } +// }, receiveValue: { item in +// completion(item) +// }) +// .store(in: &cancellables) + } +} diff --git a/Swiftfin/Objects/DeepLink.swift b/Swiftfin/Objects/DeepLink.swift new file mode 100644 index 00000000..20d226b7 --- /dev/null +++ b/Swiftfin/Objects/DeepLink.swift @@ -0,0 +1,14 @@ +// +// 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 Foundation +import JellyfinAPI + +enum DeepLink { + case item(BaseItemDto) +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg new file mode 100644 index 00000000..0359f76d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-blue + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json new file mode 100644 index 00000000..ad53b2dc --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg new file mode 100644 index 00000000..fd482fa9 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-green + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json new file mode 100644 index 00000000..1f84f6cb --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-green.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg new file mode 100644 index 00000000..df67de89 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-jellyfin + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json new file mode 100644 index 00000000..151add20 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-jellyfin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg new file mode 100644 index 00000000..62a768a5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-orange + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json new file mode 100644 index 00000000..c1b388f6 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-orange.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg new file mode 100644 index 00000000..6c848265 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-red + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json new file mode 100644 index 00000000..7158d1b8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-red.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg new file mode 100644 index 00000000..9787edce --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg @@ -0,0 +1,16 @@ + + + AppIcon-dark-yellow + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json new file mode 100644 index 00000000..a437da5b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-yellow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg new file mode 100644 index 00000000..5183ef54 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-blue + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json new file mode 100644 index 00000000..ee9343e8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg new file mode 100644 index 00000000..d0438c88 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-green + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json new file mode 100644 index 00000000..7776bbd2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-green.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg new file mode 100644 index 00000000..f551bcb8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-jellyfin + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json new file mode 100644 index 00000000..5a7cb929 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-jellyfin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg new file mode 100644 index 00000000..855ae608 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-orange + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json new file mode 100644 index 00000000..f58bd11f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-orange.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg new file mode 100644 index 00000000..6d6f5085 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-red + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json new file mode 100644 index 00000000..745880cd --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-red.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg new file mode 100644 index 00000000..9935c5cd --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedDark-yellow + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json new file mode 100644 index 00000000..acddd76a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedDark-yellow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg new file mode 100644 index 00000000..7fe53b01 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-blue + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json new file mode 100644 index 00000000..919ded90 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg new file mode 100644 index 00000000..1b434671 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-green + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json new file mode 100644 index 00000000..e75c6e7f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-green.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg new file mode 100644 index 00000000..03df4bb1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-jellyfin + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json new file mode 100644 index 00000000..40b430ea --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-jellyfin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg new file mode 100644 index 00000000..46a6c39b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-orange + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json new file mode 100644 index 00000000..7e5cb9c8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-orange.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg new file mode 100644 index 00000000..9f251a13 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-red + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json new file mode 100644 index 00000000..97664edb --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-red.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg new file mode 100644 index 00000000..72209c33 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg @@ -0,0 +1,16 @@ + + + AppIcon-invertedLight-yellow + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json new file mode 100644 index 00000000..c48c59cc --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-invertedLight-yellow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg new file mode 100644 index 00000000..bc41f6e1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-blue + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json new file mode 100644 index 00000000..6e49249a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg new file mode 100644 index 00000000..b48a3ce1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-green + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json new file mode 100644 index 00000000..ca6d643d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-green.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg new file mode 100644 index 00000000..d23e85d5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-jellyfin + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json new file mode 100644 index 00000000..17df257e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-jellyfin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg new file mode 100644 index 00000000..bb1a78ef --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-orange + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json new file mode 100644 index 00000000..482ac97e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-orange.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg new file mode 100644 index 00000000..9101ca42 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-red + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json new file mode 100644 index 00000000..15cfbf82 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-red.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg new file mode 100644 index 00000000..08b69104 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg @@ -0,0 +1,16 @@ + + + AppIcon-light-yellow + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json new file mode 100644 index 00000000..892b39fa --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-yellow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg b/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg new file mode 100644 index 00000000..b9734257 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg @@ -0,0 +1,16 @@ + + + primary + + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json new file mode 100644 index 00000000..fb781a9c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "AppIcon-primary-primary.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json rename to Swiftfin/Resources/Assets.xcassets/AppIcons/Contents.json diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png new file mode 100644 index 00000000..f56341f3 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json new file mode 100644 index 00000000..5e53a352 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png new file mode 100644 index 00000000..8fc342f6 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json new file mode 100644 index 00000000..2698c56b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png new file mode 100644 index 00000000..a1de8f95 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..43c18692 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png new file mode 100644 index 00000000..04c2b7fa Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json new file mode 100644 index 00000000..0cce5543 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png new file mode 100644 index 00000000..b576b17e Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json new file mode 100644 index 00000000..93aa3d95 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png new file mode 100644 index 00000000..a81a04f9 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json new file mode 100644 index 00000000..d228c5f4 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json rename to Swiftfin/Resources/Assets.xcassets/AppIcons/Dark/Contents.json diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json new file mode 100644 index 00000000..f576456f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png new file mode 100644 index 00000000..833df3f7 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json new file mode 100644 index 00000000..bdf1b75e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png new file mode 100644 index 00000000..8e2f25f2 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..b6aee0a8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png new file mode 100644 index 00000000..988a97ec Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json new file mode 100644 index 00000000..43c3f0b1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png new file mode 100644 index 00000000..6d95620d Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json new file mode 100644 index 00000000..a3eb9a85 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png new file mode 100644 index 00000000..db99e9a0 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json new file mode 100644 index 00000000..2b34f525 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png new file mode 100644 index 00000000..bd72e8ee Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Contents.json rename to Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json new file mode 100644 index 00000000..f576456f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png new file mode 100644 index 00000000..545ffe83 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json new file mode 100644 index 00000000..bdf1b75e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png new file mode 100644 index 00000000..ba312208 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..b6aee0a8 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png new file mode 100644 index 00000000..91aa2dd4 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json new file mode 100644 index 00000000..43c3f0b1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png new file mode 100644 index 00000000..c25a02d9 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json new file mode 100644 index 00000000..a3eb9a85 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png new file mode 100644 index 00000000..c96f50ad Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json new file mode 100644 index 00000000..2b34f525 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png new file mode 100644 index 00000000..29bc419b Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json rename to Swiftfin/Resources/Assets.xcassets/AppIcons/Inverted-Light/Contents.json diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png new file mode 100644 index 00000000..964a0c99 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json new file mode 100644 index 00000000..a1ed8c80 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png new file mode 100644 index 00000000..8918bc60 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json new file mode 100644 index 00000000..7a435a7a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png new file mode 100644 index 00000000..93512970 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..63e1b06b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png new file mode 100644 index 00000000..8b774a89 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json new file mode 100644 index 00000000..bdec0e39 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png new file mode 100644 index 00000000..a7957d09 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json new file mode 100644 index 00000000..b42150ac --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png new file mode 100644 index 00000000..a42b65a7 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json new file mode 100644 index 00000000..43413c67 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Light/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png new file mode 100644 index 00000000..3b527bd7 Binary files /dev/null and b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png differ diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json new file mode 100644 index 00000000..b9bffe0d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-primary-primary.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/Contents.json b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/AppIcons/Primary/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/Contents.json b/Swiftfin/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json diff --git a/jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg rename to Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg diff --git a/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/Contents.json b/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/Contents.json new file mode 100644 index 00000000..c6230986 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "git.commit.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg b/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg new file mode 100644 index 00000000..392aa6f1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from git.commit + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json new file mode 100644 index 00000000..25a4a3a2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jellyfin-blob.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg new file mode 100644 index 00000000..db72d151 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg @@ -0,0 +1,15 @@ + + + Combined Shape + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/Contents.json b/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/Contents.json new file mode 100644 index 00000000..9ccfab3b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "logo.github.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg b/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg new file mode 100644 index 00000000..2588c07a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from logo.github + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json b/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json new file mode 100644 index 00000000..3d67d001 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "tomato.fresh.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg b/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg new file mode 100644 index 00000000..d84fcc47 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg @@ -0,0 +1,108 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from tomato.fresh + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json b/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json new file mode 100644 index 00000000..4539290b --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "tomato.rotten.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg b/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg new file mode 100644 index 00000000..47e742da --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg @@ -0,0 +1,97 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from tomato.rotten + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Resources/Info.plist b/Swiftfin/Resources/Info.plist new file mode 100644 index 00000000..90bbd411 --- /dev/null +++ b/Swiftfin/Resources/Info.plist @@ -0,0 +1,102 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + jellyfin + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBluetoothAlwaysUsageDescription + ${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices. + NSBluetoothPeripheralUsageDescription + ${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices. + NSBonjourServices + + _googlecast._tcp + _F007D354._googlecast._tcp + + NSFaceIDUsageDescription + Use FaceID to lock and access local users. + NSLocalNetworkUsageDescription + ${PRODUCT_NAME} uses the local network to connect to your Jellyfin server & discover Cast-enabled devices on your WiFi +network. + NSMicrophoneUsageDescription + ${PRODUCT_NAME} uses microphone access to listen for ultrasonic tokens when pairing with nearby Cast devices. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + + UIDesignRequiresCompatibility + + UIFileSharingEnabled + + UILaunchScreen + + UIImageName + jellyfin-blob-blue + UIImageRespectsSafeAreaInsets + + + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationLandscapeLeft + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Swiftfin/Resources/Swiftfin 2.entitlements b/Swiftfin/Resources/Swiftfin 2.entitlements new file mode 100644 index 00000000..ee95ab7e --- /dev/null +++ b/Swiftfin/Resources/Swiftfin 2.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Swiftfin/Resources/Swiftfin.entitlements b/Swiftfin/Resources/Swiftfin.entitlements new file mode 100644 index 00000000..ee95ab7e --- /dev/null +++ b/Swiftfin/Resources/Swiftfin.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Swiftfin/Views/AboutAppView.swift b/Swiftfin/Views/AboutAppView.swift new file mode 100644 index 00000000..c4d6ef9b --- /dev/null +++ b/Swiftfin/Views/AboutAppView.swift @@ -0,0 +1,66 @@ +// +// 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 AboutAppView: View { + + var body: some View { + List { + Section { + VStack(alignment: .center, spacing: 10) { + + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(height: 150) + + Text(verbatim: "Swiftfin") + .fontWeight(.semibold) + .font(.title2) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + + Section { + + LabeledContent( + L10n.version, + value: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" + ) + + ChevronButton( + L10n.sourceCode, + image: .logoGithub, + external: true + ) { + UIApplication.shared.open(.swiftfinGithub) + } + + ChevronButton( + L10n.bugsAndFeatures, + systemName: "plus.circle.fill", + external: true + ) { + UIApplication.shared.open(.swiftfinGithubIssues) + } + .symbolRenderingMode(.monochrome) + + ChevronButton( + L10n.settings, + systemName: "gearshape.fill", + external: true + ) { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/APIKeyView/APIKeysView.swift b/Swiftfin/Views/AdminDashboardView/APIKeyView/APIKeysView.swift new file mode 100644 index 00000000..7a64008b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/APIKeyView/APIKeysView.swift @@ -0,0 +1,110 @@ +// +// 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 + +struct APIKeysView: View { + + @Router + private var router + + @State + private var appName: String = "" + @State + private var showCreateAPIAlert = false + + @StateObject + private var viewModel = APIKeysViewModel() + + private var contentView: some View { + List { + ListTitleSection( + L10n.apiKeysTitle, + description: L10n.apiKeysDescription + ) + + if viewModel.apiKeys.isNotEmpty { + ForEach(viewModel.apiKeys, id: \.accessToken) { apiKey in + APIKeysRow( + apiKey: apiKey + ) { + viewModel.delete(key: apiKey) + } replaceAction: { + viewModel.replace(key: apiKey) + } + } + } else { + Button(L10n.add) { + showCreateAPIAlert = true + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.refresh() + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + contentView + case .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .animation(.linear(duration: 0.1), value: viewModel.apiKeys) + .navigationTitle(L10n.apiKeys) + .onFirstAppear { + viewModel.refresh() + } + .topBarTrailing { + + if viewModel.background.is(.updating) { + ProgressView() + } + + if viewModel.apiKeys.isNotEmpty { + Button(L10n.add) { + showCreateAPIAlert = true + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + } + } + .alert( + L10n.createAPIKey, + isPresented: $showCreateAPIAlert + ) { + TextField(L10n.applicationName, text: $appName) + Button(L10n.cancel, role: .cancel) {} + Button(L10n.save) { + viewModel.create(name: appName) + appName = "" + } + } message: { + Text(L10n.createAPIKeyMessage) + } + .onReceive(viewModel.events) { event in + switch event { + case .createdKey: + UIDevice.feedback(.success) + } + } + .errorMessage($viewModel.error) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift b/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift new file mode 100644 index 00000000..d52905c1 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift @@ -0,0 +1,120 @@ +// +// 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 APIKeysView { + + struct APIKeysRow: View { + + @State + private var showCopiedAlert = false + @State + private var showDeleteConfirmation = false + @State + private var showReplaceConfirmation = false + + let apiKey: AuthenticationInfo + let deleteAction: () -> Void + let replaceAction: () -> Void + + @ViewBuilder + private var rowContent: some View { + VStack(alignment: .leading, spacing: 4) { + Text(apiKey.appName ?? L10n.unknown) + .fontWeight(.semibold) + .lineLimit(2) + + Text(apiKey.accessToken ?? L10n.unknown) + .lineLimit(2) + + LabeledContent(L10n.dateCreated) { + if let creationDate = apiKey.dateCreated { + Text(creationDate, format: .dateTime) + } else { + Text(L10n.unknown) + } + } + .monospacedDigit() + } + .font(.subheadline) + .multilineTextAlignment(.leading) + } + + var body: some View { + Button { + UIPasteboard.general.string = apiKey.accessToken + showCopiedAlert = true + } label: { + rowContent + } + .foregroundStyle(.primary, .secondary) + .alert( + L10n.apiKeyCopied, + isPresented: $showCopiedAlert + ) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.apiKeyCopiedMessage) + } + .confirmationDialog( + L10n.delete, + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button( + L10n.delete, + role: .destructive, + action: deleteAction + ) + Button( + L10n.cancel, + role: .cancel + ) {} + } message: { + Text(L10n.deleteItemConfirmation) + } + .confirmationDialog( + L10n.replace, + isPresented: $showReplaceConfirmation, + titleVisibility: .visible + ) { + Button( + L10n.replace, + role: .destructive, + action: replaceAction + ) + Button( + L10n.cancel, + role: .cancel + ) {} + } message: { + Text(L10n.replaceItemConfirmation) + } + .swipeActions { + + Button( + L10n.delete, + systemImage: "trash" + ) { + showDeleteConfirmation = true + } + .tint(.red) + + Button( + L10n.replace, + systemImage: "arrow.clockwise" + ) { + showReplaceConfirmation = true + } + .tint(.blue) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection 2.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection 2.swift new file mode 100644 index 00000000..0e7327ab --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection 2.swift @@ -0,0 +1,67 @@ +// +// 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 ActiveSessionDetailView { + + struct StreamSection: View { + + let nowPlayingItem: BaseItemDto + let transcodingInfo: TranscodingInfo? + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading) { + + // Create the Audio Codec Flow if the stream uses Audio + if let sourceAudioCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .audio })?.codec { + getMediaComparison( + sourceComponent: sourceAudioCodec, + destinationComponent: transcodingInfo?.audioCodec ?? sourceAudioCodec + ) + } + + // Create the Video Codec Flow if the stream uses Video + if let sourceVideoCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .video })?.codec { + getMediaComparison( + sourceComponent: sourceVideoCodec, + destinationComponent: transcodingInfo?.videoCodec ?? sourceVideoCodec + ) + } + + // Create the Container Flow if the stream has a Container + if let sourceContainer = nowPlayingItem.container { + getMediaComparison( + sourceComponent: sourceContainer, + destinationComponent: transcodingInfo?.container ?? sourceContainer + ) + } + } + } + + // MARK: - Transcoding Details + + @ViewBuilder + private func getMediaComparison(sourceComponent: String, destinationComponent: String) -> some View { + HStack { + Text(sourceComponent) + .frame(maxWidth: .infinity, alignment: .trailing) + + Image(systemName: (destinationComponent != sourceComponent) ? "shuffle" : "arrow.right") + .frame(maxWidth: .infinity, alignment: .center) + + Text(destinationComponent) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift new file mode 100644 index 00000000..0e7327ab --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift @@ -0,0 +1,67 @@ +// +// 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 ActiveSessionDetailView { + + struct StreamSection: View { + + let nowPlayingItem: BaseItemDto + let transcodingInfo: TranscodingInfo? + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading) { + + // Create the Audio Codec Flow if the stream uses Audio + if let sourceAudioCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .audio })?.codec { + getMediaComparison( + sourceComponent: sourceAudioCodec, + destinationComponent: transcodingInfo?.audioCodec ?? sourceAudioCodec + ) + } + + // Create the Video Codec Flow if the stream uses Video + if let sourceVideoCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .video })?.codec { + getMediaComparison( + sourceComponent: sourceVideoCodec, + destinationComponent: transcodingInfo?.videoCodec ?? sourceVideoCodec + ) + } + + // Create the Container Flow if the stream has a Container + if let sourceContainer = nowPlayingItem.container { + getMediaComparison( + sourceComponent: sourceContainer, + destinationComponent: transcodingInfo?.container ?? sourceContainer + ) + } + } + } + + // MARK: - Transcoding Details + + @ViewBuilder + private func getMediaComparison(sourceComponent: String, destinationComponent: String) -> some View { + HStack { + Text(sourceComponent) + .frame(maxWidth: .infinity, alignment: .trailing) + + Image(systemName: (destinationComponent != sourceComponent) ? "shuffle" : "arrow.right") + .frame(maxWidth: .infinity, alignment: .center) + + Text(destinationComponent) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift new file mode 100644 index 00000000..824a0778 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift @@ -0,0 +1,43 @@ +// +// 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 ActiveSessionDetailView { + + struct TranscodeSection: View { + + let transcodeReasons: [TranscodeReason] + + // MARK: - Body + + var body: some View { + VStack(alignment: .center) { + + let transcodeIcons = Set(transcodeReasons.map(\.systemImage)).sorted() + + HStack { + ForEach(transcodeIcons, id: \.self) { icon in + Image(systemName: icon) + .foregroundStyle(.primary) + .symbolRenderingMode(.monochrome) + } + } + + Divider() + + ForEach(transcodeReasons, id: \.self) { reason in + Text(reason) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift new file mode 100644 index 00000000..03b7fdf9 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift @@ -0,0 +1,126 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI +import SwiftUIIntrospect + +struct ActiveSessionDetailView: View { + + @Router + private var router + + @ObservedObject + var box: BindingBox + + // MARK: Create Idle Content View + + @ViewBuilder + private func idleContent(session: SessionInfoDto) -> some View { + List { + if let userID = session.userID { + let user = UserDto(id: userID, name: session.userName) + + AdminDashboardView.UserSection( + user: user, + lastActivityDate: session.lastActivityDate + ) { + router.route(to: .userDetails(user: user)) + } + } + + AdminDashboardView.DeviceSection( + client: session.client, + device: session.deviceName, + version: session.applicationVersion + ) + } + } + + // MARK: Create Session Content View + + @ViewBuilder + private func sessionContent( + session: SessionInfoDto, + nowPlayingItem: BaseItemDto, + playState: PlayerStateInfo + ) -> some View { + List { + + AdminDashboardView.MediaItemSection(item: nowPlayingItem) + + Section(L10n.progress) { + ActiveSessionsView.ProgressSection( + item: nowPlayingItem, + playState: playState, + transcodingInfo: session.transcodingInfo + ) + } + + if let userID = session.userID { + let user = UserDto(id: userID, name: session.userName) + + AdminDashboardView.UserSection( + user: user, + lastActivityDate: session.lastPlaybackCheckIn + ) { + router.route(to: .userDetails(user: user)) + } + } + + AdminDashboardView.DeviceSection( + client: session.client, + device: session.deviceName, + version: session.applicationVersion + ) + + // TODO: allow showing item stream details? + // TODO: don't show codec changes on direct play? + Section(L10n.streams) { + if let playMethodDisplayTitle = session.playMethodDisplayTitle { + LabeledContent( + L10n.method, + value: playMethodDisplayTitle + ) + } + + StreamSection( + nowPlayingItem: nowPlayingItem, + transcodingInfo: session.transcodingInfo + ) + } + + if let transcodeReasons = session.transcodingInfo?.transcodeReasons, transcodeReasons.isNotEmpty { + Section(L10n.transcodeReasons) { + TranscodeSection(transcodeReasons: transcodeReasons) + } + } + } + } + + var body: some View { + ZStack { + if let session = box.value { + if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { + sessionContent( + session: session, + nowPlayingItem: nowPlayingItem, + playState: playState + ) + } else { + idleContent(session: session) + } + } else { + Text(L10n.noSession) + } + } + .animation(.linear(duration: 0.2), value: box.value) + .navigationTitle(L10n.session) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift new file mode 100644 index 00000000..0c22871b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift @@ -0,0 +1,172 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +struct ActiveSessionsView: View { + + @Default(.accentColor) + private var accentColor + + // MARK: - Router + + @Router + private var router + + // MARK: - Track Filter State + + @State + private var isFiltersPresented = false + + @StateObject + private var viewModel = ActiveSessionsViewModel() + + private let timer = Timer.publish(every: 5, on: .main, in: .common) + .autoconnect() + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.sessions.isEmpty { + L10n.none.text + } else { + CollectionVGrid( + uniqueElements: viewModel.sessions.keys, + id: \.self, + layout: .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) + ) { id in + ActiveSessionRow(box: viewModel.sessions[id]!) { + router.route( + to: .activeDeviceDetails(box: viewModel.sessions[id]!) + ) + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.refresh() + } + } + + // MARK: - Body + + @ViewBuilder + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + contentView + case .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.sessions) + .navigationBarTitleDisplayMode(.inline) + .topBarTrailing { + if viewModel.background.is(.refreshing) { + ProgressView() + } + + Menu(L10n.filters, systemImage: "line.3.horizontal.decrease.circle") { + activeWithinFilterButton + showInactiveSessionsButton + } + .menuStyle(.button) + .buttonStyle(.isPressed { isPressed in + isFiltersPresented = isPressed + }) + .foregroundStyle(accentColor) + } + .onFirstAppear { + viewModel.refresh() + } + .onReceive(timer) { _ in + guard !isFiltersPresented else { return } + viewModel.background.refresh() + } + } + + // MARK: - Active Within Filter Button + + @ViewBuilder + private var activeWithinFilterButton: some View { + Picker(selection: $viewModel.activeWithinSeconds) { + Label( + L10n.all, + systemImage: "infinity" + ) + .tag(nil as Int?) + + Label( + Duration.seconds(300).formatted(.hourMinuteAbbreviated), + systemImage: "clock" + ) + .tag(300 as Int?) + + Label( + Duration.seconds(900).formatted(.hourMinuteAbbreviated), + systemImage: "clock" + ) + .tag(900 as Int?) + + Label( + Duration.seconds(1800).formatted(.hourMinuteAbbreviated), + systemImage: "clock" + ) + .tag(1800 as Int?) + + Label( + Duration.seconds(3600).formatted(.hourMinuteAbbreviated), + systemImage: "clock" + ) + .tag(3600 as Int?) + } label: { + Text(L10n.lastSeen) + + if let activeWithinSeconds = viewModel.activeWithinSeconds { + Text(Duration.seconds(activeWithinSeconds).formatted(.units(allowed: [.hours, .minutes]))) + } else { + Text(L10n.all) + } + + Image(systemName: viewModel.activeWithinSeconds == nil ? "infinity" : "clock") + } + .pickerStyle(.menu) + } + + // MARK: - Show Inactive Sessions Button + + @ViewBuilder + private var showInactiveSessionsButton: some View { + Picker(selection: $viewModel.showSessionType) { + ForEach(ActiveSessionFilter.allCases, id: \.self) { filter in + Label( + filter.displayTitle, + systemImage: filter.systemImage + ) + .tag(filter) + } + } label: { + Text(L10n.sessions) + Text(viewModel.showSessionType.displayTitle) + Image(systemName: viewModel.showSessionType.systemImage) + } + .pickerStyle(.menu) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift new file mode 100644 index 00000000..4c244602 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift @@ -0,0 +1,106 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ActiveSessionsView { + + struct ProgressSection: View { + + @Default(.accentColor) + private var accentColor + + private let item: BaseItemDto + private let playState: PlayerStateInfo + private let transcodingInfo: TranscodingInfo? + private let showTranscodeReason: Bool + + private var playbackPercentage: Double { + clamp(Double(playState.positionTicks ?? 0) / Double(item.runTimeTicks ?? 1), min: 0, max: 1) + } + + private var transcodingPercentage: Double? { + guard let c = transcodingInfo?.completionPercentage else { return nil } + return clamp(c / 100.0, min: 0, max: 1) + } + + init(item: BaseItemDto, playState: PlayerStateInfo, transcodingInfo: TranscodingInfo?, showTranscodeReason: Bool = false) { + self.item = item + self.playState = playState + self.transcodingInfo = transcodingInfo + self.showTranscodeReason = showTranscodeReason + } + + @ViewBuilder + private var playbackInformation: some View { + HStack(alignment: .top) { + FlowLayout( + alignment: .leading, + direction: .down, + spacing: 4, + lineSpacing: 4, + minRowLength: 1 + ) { + if playState.isPaused ?? false { + Image(systemName: "pause.fill") + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .padding(.trailing, 8) + } else { + Image(systemName: "play.fill") + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .padding(.trailing, 8) + } + + if let playMethod = playState.playMethod, + let transcodeReasons = transcodingInfo?.transcodeReasons, + playMethod == .transcode + { + if showTranscodeReason { + let transcodeIcons = Set(transcodeReasons.map(\.systemImage)).sorted() + + ForEach(transcodeIcons, id: \.self) { icon in + Image(systemName: icon) + .foregroundStyle(.secondary) + .symbolRenderingMode(.monochrome) + } + } + + Text(playMethod) + .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 2) { + Text(playState.position ?? .zero, format: .runtime) + + Text("/") + + Text(item.runtime ?? .zero, format: .runtime) + } + .monospacedDigit() + .fixedSize(horizontal: true, vertical: true) + } + .font(.subheadline) + } + + var body: some View { + VStack { + ProgressView(value: playbackPercentage) + .progressViewStyle(.playback.secondaryProgress(transcodingPercentage)) + .frame(height: 5) + .foregroundStyle(.primary, .secondary, .orange) + + playbackInformation + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift new file mode 100644 index 00000000..47418c4a --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -0,0 +1,130 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ActiveSessionsView { + + struct ActiveSessionRow: View { + + @CurrentDate + private var currentDate: Date + + @ObservedObject + private var box: BindingBox + + private let onSelect: () -> Void + + private var session: SessionInfoDto { + box.value ?? .init() + } + + init(box: BindingBox, onSelect action: @escaping () -> Void) { + self.box = box + self.onSelect = action + } + + @ViewBuilder + private var rowLeading: some View { + Group { + if let nowPlayingItem = session.nowPlayingItem { + PosterImage( + item: nowPlayingItem, + type: nowPlayingItem.preferredPosterDisplayType, + contentMode: .fit + ) + .frame(width: 60) + } else { + ZStack { + session.device.clientColor + + Image(session.device.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + } + .posterStyle(.square) + .frame(width: 60, height: 60) + } + } + .frame(width: 60, height: 90) + .posterShadow() + .padding(.vertical, 8) + } + + @ViewBuilder + private func activeSessionDetails(_ nowPlayingItem: BaseItemDto, playState: PlayerStateInfo) -> some View { + VStack(alignment: .leading) { + Text(session.userName ?? L10n.unknown) + .multilineTextAlignment(.leading) + .font(.headline) + + Text(nowPlayingItem.name ?? L10n.unknown) + .multilineTextAlignment(.leading) + .lineLimit(2) + + ProgressSection( + item: nowPlayingItem, + playState: playState, + transcodingInfo: session.transcodingInfo, + showTranscodeReason: true + ) + } + .font(.subheadline) + } + + @ViewBuilder + private var idleSessionDetails: some View { + VStack(alignment: .leading) { + + Text(session.userName ?? L10n.unknown) + .font(.headline) + + if let client = session.client { + LabeledContent( + L10n.client, + value: client + ) + } + + if let device = session.deviceName { + LabeledContent( + L10n.device, + value: device + ) + } + + if let lastActivityDate = session.lastActivityDate { + LabeledContent( + L10n.lastSeen, + value: lastActivityDate, + format: .lastSeen + ) + .id(currentDate) + .monospacedDigit() + } + } + .font(.subheadline) + } + + var body: some View { + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { + activeSessionDetails(nowPlayingItem, playState: playState) + } else { + idleSessionDetails + } + } + .onSelect(perform: onSelect) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift new file mode 100644 index 00000000..0b12e32c --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift @@ -0,0 +1,60 @@ +// +// 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 AdminDashboardView: View { + + @Router + private var router + + // MARK: - Body + + var body: some View { + List { + + ListTitleSection( + L10n.dashboard, + description: L10n.dashboardDescription + ) + + ChevronButton(L10n.sessions) { + router.route(to: .activeSessions) + } + + Section(L10n.activity) { + ChevronButton(L10n.activity) { + router.route(to: .activity) + } + ChevronButton(L10n.devices) { + router.route(to: .devices) + } + ChevronButton(L10n.users) { + router.route(to: .users) + } + } + + Section(L10n.advanced) { + + ChevronButton(L10n.apiKeys) { + router.route(to: .apiKeys) + } + + ChevronButton(L10n.logs) { + router.route(to: .serverLogs) + } + + ChevronButton(L10n.tasks) { + router.route(to: .tasks) + } + } + } + .navigationTitle(L10n.dashboard) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/Components/DeviceSection.swift b/Swiftfin/Views/AdminDashboardView/Components/DeviceSection.swift new file mode 100644 index 00000000..3411fb1d --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/Components/DeviceSection.swift @@ -0,0 +1,39 @@ +// +// 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 AdminDashboardView { + + struct DeviceSection: View { + + let client: String? + let device: String? + let version: String? + + var body: some View { + Section(L10n.device) { + LabeledContent( + L10n.device, + value: device ?? L10n.unknown + ) + + LabeledContent( + L10n.client, + value: client ?? L10n.unknown + ) + + LabeledContent( + L10n.version, + value: version ?? L10n.unknown + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift b/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift new file mode 100644 index 00000000..ba65478f --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/Components/MediaItemSection.swift @@ -0,0 +1,58 @@ +// +// 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 AdminDashboardView { + + struct MediaItemSection: View { + + let item: BaseItemDto + + var body: some View { + Section { + HStack(alignment: .bottom, spacing: 12) { + PosterImage( + item: item, + type: item.preferredPosterDisplayType, + contentMode: .fit + ) + .frame(width: 100) + .accessibilityIgnoresInvertColors() + + VStack(alignment: .leading) { + + if let parent = item.parentTitle { + Text(parent) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(item.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + + if let subtitle = item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.bottom) + } + } + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/Components/UserSection.swift b/Swiftfin/Views/AdminDashboardView/Components/UserSection.swift new file mode 100644 index 00000000..e96e0e73 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/Components/UserSection.swift @@ -0,0 +1,58 @@ +// +// 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 AdminDashboardView { + + struct UserSection: View { + + @CurrentDate + private var currentDate: Date + + private let user: UserDto + private let lastActivityDate: Date? + private let action: (() -> Void)? + + // MARK: - Initializer + + init(user: UserDto, lastActivityDate: Date? = nil, action: (() -> Void)? = nil) { + self.user = user + self.lastActivityDate = lastActivityDate + self.action = action + } + + // MARK: - Body + + var body: some View { + Section(L10n.user) { + profileView + LabeledContent(L10n.lastSeen, value: lastActivityDate, format: .lastSeen) + .id(currentDate) + .monospacedDigit() + } + } + + // MARK: - Profile View + + private var profileView: some View { + if let onSelect = action { + SettingsView.UserProfileRow( + user: user + ) { + onSelect() + } + } else { + SettingsView.UserProfileRow( + user: user + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift new file mode 100644 index 00000000..343ac559 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift @@ -0,0 +1,87 @@ +// +// 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 + +struct ServerActivityDetailsView: View { + + // MARK: - Environment Objects + + @Router + private var router + + // MARK: - Activity Log Entry Variable + + @StateObject + var viewModel: ServerActivityDetailViewModel + + // MARK: - Body + + var body: some View { + List { + /// Item (If Available) + if let item = viewModel.item { + AdminDashboardView.MediaItemSection(item: item) + } + + /// User (If Available) + if let user = viewModel.user { + AdminDashboardView.UserSection( + user: user, + lastActivityDate: viewModel.log.date + ) { + router.route(to: .userDetails(user: user)) + } + } + + /// Event Name & Overview + Section(L10n.overview) { + if let name = viewModel.log.name, name.isNotEmpty { + Text(name) + } + if let overview = viewModel.log.overview, overview.isNotEmpty { + Text(overview) + } else if let shortOverview = viewModel.log.shortOverview, shortOverview.isNotEmpty { + Text(shortOverview) + } + } + + /// Event Details + Section(L10n.details) { + if let severity = viewModel.log.severity { + LabeledContent( + L10n.level, + value: severity.displayTitle + ) + } + if let type = viewModel.log.type { + LabeledContent( + L10n.type, + value: type + ) + } + if let date = viewModel.log.date { + LabeledContent( + L10n.date, + value: date.formatted(date: .long, time: .shortened) + ) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle( + L10n.activityLog + .localizedCapitalized + ) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.refresh) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift new file mode 100644 index 00000000..61344b80 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift @@ -0,0 +1,87 @@ +// +// 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 CollectionVGrid +import JellyfinAPI +import SwiftUI + +struct ServerActivityFilterView: View { + + // MARK: - Environment Objects + + @Router + private var router + + // MARK: - State Objects + + @ObservedObject + private var viewModel: ServerActivityViewModel + + // MARK: - Dialog States + + @State + private var tempDate: Date? + + // MARK: - Initializer + + init(viewModel: ServerActivityViewModel) { + + self.viewModel = viewModel + + if let minDate = viewModel.minDate { + tempDate = minDate + } else { + tempDate = .now + } + } + + // MARK: - Body + + var body: some View { + List { + Section { + DatePicker( + L10n.date, + selection: $tempDate.coalesce(.now), + in: ...Date.now, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + .labelsHidden() + } + + /// Reset button to remove the filter + if viewModel.minDate != nil { + Section { + ListRowButton(L10n.reset, role: .destructive) { + viewModel.minDate = nil + router.dismiss() + } + } footer: { + Text(L10n.resetFilterFooter) + } + } + } + .navigationTitle(L10n.startDate.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + let startOfDay = Calendar.current + .startOfDay(for: tempDate ?? .now) + + Button(L10n.save) { + viewModel.minDate = startOfDay + router.dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift new file mode 100644 index 00000000..35595940 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift @@ -0,0 +1,102 @@ +// +// 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 ServerActivityView { + + struct LogEntry: View { + + // MARK: - Activity Log Entry Variable + + @StateObject + var viewModel: ServerActivityDetailViewModel + + // MARK: - Action Variable + + let action: () -> Void + + // MARK: - Body + + var body: some View { + ListRow { + userImage + } content: { + rowContent + .padding(.bottom, 8) + } + .onSelect(perform: action) + } + + // MARK: - User Image + + @ViewBuilder + private var userImage: some View { + let imageSource = viewModel.user?.profileImageSource(client: viewModel.userSession.client, maxWidth: 60) ?? .init() + + UserProfileImage( + userID: viewModel.log.userID ?? viewModel.userSession?.user.id, + source: imageSource + ) { + SystemImageContentView( + systemName: viewModel.user != nil ? "person.fill" : "gearshape.fill", + ratio: 0.5 + ) + } + .frame(width: 60, height: 60) + } + + // MARK: - User Image + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + /// Event Severity & Username / System + HStack(spacing: 8) { + Image(systemName: viewModel.log.severity?.systemImage ?? "questionmark.circle") + .foregroundStyle(viewModel.log.severity?.color ?? .gray) + + if viewModel.user != nil { + Text(viewModel.user?.name ?? L10n.unknown) + } else { + Text(L10n.system) + } + } + .font(.headline) + + /// Event Name + Text(viewModel.log.name ?? .emptyDash) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Group { + if let eventDate = viewModel.log.date { + Text(eventDate.formatted(date: .abbreviated, time: .standard)) + } else { + Text(String.emptyRuntime) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "chevron.right") + .padding() + .font(.body.weight(.regular)) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift new file mode 100644 index 00000000..63452545 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift @@ -0,0 +1,152 @@ +// +// 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 CollectionVGrid +import JellyfinAPI +import SwiftUI + +// TODO: WebSocket +struct ServerActivityView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - State Objects + + @StateObject + private var viewModel = ServerActivityViewModel() + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.activity) + .navigationBarTitleDisplayMode(.inline) + .topBarTrailing { + if viewModel.backgroundStates.contains(.gettingNextPage) { + ProgressView() + } + + Menu(L10n.filters, systemImage: "line.3.horizontal.decrease.circle") { + startDateButton + userFilterButton + } + } + .onFirstAppear { + viewModel.send(.refresh) + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.elements.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + CollectionVGrid( + uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, + layout: .columns(1) + ) { log in + + let user = viewModel.users.first( + property: \.id, + equalTo: log.userID + ) + + let logViewModel = ServerActivityDetailViewModel( + log: log, + user: user + ) + + LogEntry(viewModel: logViewModel) { + router.route(to: .activityDetails(viewModel: logViewModel)) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + .frame(maxWidth: .infinity) + } + } + + // MARK: - User Filter Button + + @ViewBuilder + private var userFilterButton: some View { + Picker(selection: $viewModel.hasUserId) { + Label( + L10n.all, + systemImage: "line.3.horizontal" + ) + .tag(nil as Bool?) + + Label( + L10n.users, + systemImage: "person" + ) + .tag(true as Bool?) + + Label( + L10n.system, + systemImage: "gearshape" + ) + .tag(false as Bool?) + } label: { + Text(L10n.type) + + if let hasUserID = viewModel.hasUserId { + Text(hasUserID ? L10n.users : L10n.system) + Image(systemName: hasUserID ? "person" : "gearshape") + + } else { + Text(L10n.all) + Image(systemName: "line.3.horizontal") + } + } + .pickerStyle(.menu) + } + + // MARK: - Start Date Button + + @ViewBuilder + private var startDateButton: some View { + Button { + router.route(to: .activityFilters(viewModel: viewModel)) + } label: { + Text(L10n.startDate) + + if let startDate = viewModel.minDate { + Text(startDate.formatted(date: .numeric, time: .omitted)) + } else { + Text(verbatim: .emptyDash) + } + + Image(systemName: "calendar") + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift b/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift new file mode 100644 index 00000000..258e7290 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift @@ -0,0 +1,28 @@ +// +// 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 DeviceDetailsView { + struct CapabilitiesSection: View { + var device: DeviceInfoDto + + var body: some View { + Section(L10n.capabilities) { + if let supportsMediaControl = device.capabilities?.isSupportsMediaControl { + LabeledContent(L10n.supportsMediaControl, value: supportsMediaControl ? L10n.yes : L10n.no) + } + + if let supportsPersistentIdentifier = device.capabilities?.isSupportsPersistentIdentifier { + LabeledContent(L10n.supportsPersistentIdentifier, value: supportsPersistentIdentifier ? L10n.yes : L10n.no) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift new file mode 100644 index 00000000..c79662cb --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift @@ -0,0 +1,95 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct DeviceDetailsView: View { + + @CurrentDate + private var currentDate: Date + + @Router + private var router + + @ObservedObject + private var viewModel: DevicesViewModel + + @State + private var temporaryCustomName: String? + + private let device: DeviceInfoDto + + init(device: DeviceInfoDto, viewModel: DevicesViewModel) { + self.device = device + self._temporaryCustomName = State(initialValue: device.customName) + self.viewModel = viewModel + } + + var body: some View { + List { + if let userID = device.lastUserID, + let userName = device.lastUserName + { + + let user = UserDto(id: userID, name: userName) + + AdminDashboardView.UserSection( + user: user, + lastActivityDate: device.dateLastActivity + ) { + router.route(to: .userDetails(user: user)) + } + } + + Section(L10n.name) { + TextField( + L10n.customName, + text: $temporaryCustomName.map( + getter: { $0 ?? "" }, + setter: { $0.isEmpty ? nil : $0 } + ) + ) + } + + AdminDashboardView.DeviceSection( + client: device.appName, + device: device.name, + version: device.appVersion + ) + + CapabilitiesSection(device: device) + } + .navigationTitle(L10n.device) + .onReceive(viewModel.events) { event in + switch event { + case .updated: + UIDevice.feedback(.success) + } + } + .topBarTrailing { + if viewModel.background.is(.updating) { + ProgressView() + } + Button(L10n.save) { + if let id = device.id { + viewModel.update( + id: id, + options: .init( + customName: temporaryCustomName + ) + ) + } + } + .buttonStyle(.toolbarPill) + .disabled(temporaryCustomName == device.customName) + } + .errorMessage($viewModel.error) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift new file mode 100644 index 00000000..02d746a9 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift @@ -0,0 +1,139 @@ +// +// 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 DevicesView { + + struct DeviceRow: View { + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment Variables + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + @CurrentDate + private var currentDate: Date + + // MARK: - Properties + + let device: DeviceInfoDto + let onSelect: () -> Void + let onDelete: (() -> Void)? + + // MARK: - Initializer + + init( + device: DeviceInfoDto, + onSelect: @escaping () -> Void, + onDelete: (() -> Void)? = nil + ) { + self.device = device + self.onSelect = onSelect + self.onDelete = onDelete + } + + // MARK: - Label Styling + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + return isSelected ? .primary : .secondary + } + + // MARK: - Device Image View + + @ViewBuilder + private var deviceImage: some View { + ZStack { + device.type.clientColor + + Image(device.type.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + + if isEditing { + Color.black + .opacity(isSelected ? 0 : 0.5) + } + } + .posterStyle(.square) + .posterShadow() + .frame(width: 60, height: 60) + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + Text(device.customName ?? device.name ?? L10n.unknown) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + + LabeledContent( + L10n.user, + value: device.lastUserName ?? L10n.unknown + ) + .lineLimit(1) + + LabeledContent( + L10n.client, + value: device.appName ?? L10n.unknown + ) + .lineLimit(1) + + LabeledContent(L10n.lastSeen, value: device.dateLastActivity, format: .lastSeen) + .id(currentDate) + .lineLimit(1) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(labelForegroundStyle, .secondary) + + Spacer() + + ListRowCheckbox() + } + } + + // MARK: - Body + + var body: some View { + ListRow { + deviceImage + } content: { + rowContent + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + .swipeActions { + if let onDelete = onDelete { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift new file mode 100644 index 00000000..a8157d5f --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift @@ -0,0 +1,222 @@ +// +// 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 JellyfinAPI +import OrderedCollections +import SwiftUI + +struct DevicesView: View { + + @Router + private var router + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedDevices: Set = [] + @State + private var isEditing: Bool = false + + @StateObject + private var viewModel = DevicesViewModel() + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.refresh() + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + contentView + case .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.devices) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.devices.isNotEmpty { + navigationBarEditView + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedDevices.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .onFirstAppear { + viewModel.refresh() + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedDevicesConfirmationActions + } message: { + Text(L10n.deleteSelectionDevicesWarning) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteDeviceConfirmationActions + } message: { + Text(L10n.deleteDeviceWarning) + } + .alert(L10n.deleteDeviceFailed, isPresented: $isPresentingSelfDeleteError) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.deleteDeviceSelfDeletion(viewModel.userSession.client.configuration.deviceName)) + } + .errorMessage($viewModel.error) + } + + // MARK: - Device List View + + @ViewBuilder + private var contentView: some View { + List { + InsetGroupedListHeader( + L10n.devices, + description: L10n.allDevicesDescription + ) { + UIApplication.shared.open(.jellyfinDocsDevices) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if viewModel.devices.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + ForEach(viewModel.devices, id: \.self) { device in + DeviceRow(device: device) { + guard let id = device.id else { return } + + if isEditing { + if selectedDevices.contains(id) { + selectedDevices.remove(id) + } else { + selectedDevices.insert(id) + } + } else { + router.route(to: .deviceDetails(device: device, viewModel: viewModel)) + } + } onDelete: { + guard let id = device.id else { return } + + selectedDevices.removeAll() + selectedDevices.insert(id) + isPresentingDeleteConfirmation = true + } + .isEditing(isEditing) + .isSelected(selectedDevices.contains(device.id ?? "")) + .listRowInsets(.edgeInsets) + } + } + } + .listStyle(.plain) + } + + // MARK: - Navigation Bar Edit Content + + @ViewBuilder + private var navigationBarEditView: some View { + if viewModel.background.is(.refreshing) { + ProgressView() + } else { + Button(isEditing ? L10n.cancel : L10n.edit) { + isEditing.toggle() + UIDevice.impact(.light) + if !isEditing { + selectedDevices.removeAll() + } + } + .buttonStyle(.toolbarPill) + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected: Bool = selectedDevices.count == viewModel.devices.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedDevices = [] + } else { + selectedDevices = Set(viewModel.devices.compactMap(\.id)) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + } + + // MARK: - Delete Selected Devices Confirmation Actions + + @ViewBuilder + private var deleteSelectedDevicesConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.delete(ids: selectedDevices) + isEditing = false + selectedDevices.removeAll() + } + } + + // MARK: - Delete Device Confirmation Actions + + @ViewBuilder + private var deleteDeviceConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let deviceToDelete = selectedDevices.first, selectedDevices.count == 1 { + if deviceToDelete == viewModel.userSession.client.configuration.deviceID { + isPresentingSelfDeleteError = true + } else { + viewModel.delete(ids: [deviceToDelete]) + selectedDevices.removeAll() + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift new file mode 100644 index 00000000..e10d96e3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift @@ -0,0 +1,87 @@ +// +// 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 + +// TODO: could filter based on known log names from server +// - ffmpeg +// - record-transcode +// TODO: download to device? +// TODO: super cool log parser? +// - separate package + +struct ServerLogsView: View { + + @StateObject + private var viewModel = ServerLogsViewModel() + + @ViewBuilder + private var contentView: some View { + List { + ListTitleSection( + L10n.serverLogs, + description: L10n.logsDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/administration/troubleshooting")!) + } + ForEach(viewModel.logs, id: \.self) { log in + Button { + let request = Paths.getLogFile(name: log.name!) + let url = viewModel.userSession.client.fullURL(with: request, queryAPIKey: true)! + + UIApplication.shared.open(url) + } label: { + HStack { + VStack(alignment: .leading) { + Text(log.name ?? .emptyDash) + + if let modifiedDate = log.dateModified { + Text(modifiedDate, format: .dateTime) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.up.forward") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary, .secondary) + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.getLogs() + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + contentView + case .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.serverLogs) + .onFirstAppear { + viewModel.getLogs() + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift new file mode 100644 index 00000000..0df3634b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift @@ -0,0 +1,134 @@ +// +// 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 + +struct AddTaskTriggerView: View { + + @ObservedObject + var observer: ServerTaskObserver + + @Router + private var router + + @State + private var isPresentingNotSaved = false + @State + private var taskTriggerInfo: TaskTriggerInfo + + static let defaultTimeOfDayTicks = 0 + static let defaultDayOfWeek: DayOfWeek = .sunday + static let defaultIntervalTicks = 36_000_000_000 + private let emptyTaskTriggerInfo: TaskTriggerInfo + + private var hasUnsavedChanges: Bool { + taskTriggerInfo != emptyTaskTriggerInfo + } + + private var isDuplicate: Bool { + observer.task.triggers?.contains(where: { $0 == taskTriggerInfo }) ?? false + } + + // MARK: - Init + + init(observer: ServerTaskObserver) { + self.observer = observer + + let newTrigger = TaskTriggerInfo( + dayOfWeek: nil, + intervalTicks: nil, + maxRuntimeTicks: nil, + timeOfDayTicks: nil, + type: TaskTriggerType.startup + ) + + _taskTriggerInfo = State(initialValue: newTrigger) + self.emptyTaskTriggerInfo = newTrigger + } + + // MARK: - View for TaskTriggerType.daily + + @ViewBuilder + private var dailyView: some View { + TimeRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - View for TaskTriggerType.weekly + + @ViewBuilder + private var weeklyView: some View { + DayOfWeekRow(taskTriggerInfo: $taskTriggerInfo) + TimeRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - View for TaskTriggerType.interval + + @ViewBuilder + private var intervalView: some View { + IntervalRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - Body + + var body: some View { + Form { + Section { + TriggerTypeRow(taskTriggerInfo: $taskTriggerInfo) + + if let taskType = taskTriggerInfo.type { + if taskType == TaskTriggerType.daily { + dailyView + } else if taskType == TaskTriggerType.weekly { + weeklyView + } else if taskType == TaskTriggerType.interval { + intervalView + } + } + } footer: { + if isDuplicate { + Label(L10n.triggerAlreadyExists, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) + } + .animation(.linear(duration: 0.2), value: isDuplicate) + .animation(.linear(duration: 0.2), value: taskTriggerInfo.type) + .interactiveDismissDisabled(true) + .navigationTitle(L10n.addTrigger.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + if hasUnsavedChanges { + isPresentingNotSaved = true + } else { + router.dismiss() + } + } + .topBarTrailing { + Button(L10n.save) { + + UIDevice.impact(.light) + + observer.send(.addTrigger(taskTriggerInfo)) + router.dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(isDuplicate) + } + .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { + router.dismiss() + } + Button(L10n.cancel, role: .cancel) { + isPresentingNotSaved = false + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift new file mode 100644 index 00000000..6a427b58 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct DayOfWeekRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + Picker( + L10n.dayOfWeek, + selection: Binding( + get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek }, + set: { taskTriggerInfo.dayOfWeek = $0 } + ) + ) { + ForEach(DayOfWeek.allCases, id: \.self) { day in + Text(day.displayTitle) + .tag(day) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow 2.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow 2.swift new file mode 100644 index 00000000..2464ba46 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow 2.swift @@ -0,0 +1,60 @@ +// +// 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 AddTaskTriggerView { + + struct IntervalRow: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempInterval: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempInterval = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.intervalTicks).minutes)) + } + + // MARK: - Body + + var body: some View { + ChevronButton( + L10n.every, + subtitle: ServerTicks( + taskTriggerInfo.intervalTicks + ).seconds.formatted(.hourMinute), + description: L10n.taskTriggerInterval + ) { + TextField( + L10n.minutes, + value: $tempInterval, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempInterval != nil && tempInterval != 0 { + taskTriggerInfo.intervalTicks = ServerTicks(minutes: tempInterval).ticks + } else { + taskTriggerInfo.intervalTicks = nil + } + } onCancel: { + if let intervalTicks = taskTriggerInfo.intervalTicks { + tempInterval = Int(ServerTicks(intervalTicks).minutes) + } else { + tempInterval = nil + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift new file mode 100644 index 00000000..2464ba46 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift @@ -0,0 +1,60 @@ +// +// 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 AddTaskTriggerView { + + struct IntervalRow: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempInterval: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempInterval = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.intervalTicks).minutes)) + } + + // MARK: - Body + + var body: some View { + ChevronButton( + L10n.every, + subtitle: ServerTicks( + taskTriggerInfo.intervalTicks + ).seconds.formatted(.hourMinute), + description: L10n.taskTriggerInterval + ) { + TextField( + L10n.minutes, + value: $tempInterval, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempInterval != nil && tempInterval != 0 { + taskTriggerInfo.intervalTicks = ServerTicks(minutes: tempInterval).ticks + } else { + taskTriggerInfo.intervalTicks = nil + } + } onCancel: { + if let intervalTicks = taskTriggerInfo.intervalTicks { + tempInterval = Int(ServerTicks(intervalTicks).minutes) + } else { + tempInterval = nil + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection 2.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection 2.swift new file mode 100644 index 00000000..83bad0a0 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection 2.swift @@ -0,0 +1,70 @@ +// +// 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 AddTaskTriggerView { + + struct TimeLimitSection: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempTimeLimit: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempTimeLimit = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.maxRuntimeTicks).hours)) + } + + // MARK: - Body + + var body: some View { + Section { + ChevronButton( + L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit + ) { + TextField( + L10n.hours, + value: $tempTimeLimit, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempTimeLimit != nil && tempTimeLimit != 0 { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + } onCancel: { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + tempTimeLimit = Int(ServerTicks(maxRuntimeTicks).hours) + } else { + tempTimeLimit = nil + } + } + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.none + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift new file mode 100644 index 00000000..83bad0a0 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift @@ -0,0 +1,70 @@ +// +// 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 AddTaskTriggerView { + + struct TimeLimitSection: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempTimeLimit: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempTimeLimit = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.maxRuntimeTicks).hours)) + } + + // MARK: - Body + + var body: some View { + Section { + ChevronButton( + L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit + ) { + TextField( + L10n.hours, + value: $tempTimeLimit, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempTimeLimit != nil && tempTimeLimit != 0 { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + } onCancel: { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + tempTimeLimit = Int(ServerTicks(maxRuntimeTicks).hours) + } else { + tempTimeLimit = nil + } + } + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.none + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow 2.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow 2.swift new file mode 100644 index 00000000..5d630bf5 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow 2.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TimeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + DatePicker( + L10n.time, + selection: Binding( + get: { + ServerTicks( + taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks + ).date + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks + } + ), + displayedComponents: .hourAndMinute + ) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift new file mode 100644 index 00000000..5d630bf5 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TimeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + DatePicker( + L10n.time, + selection: Binding( + get: { + ServerTicks( + taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks + ).date + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks + } + ), + displayedComponents: .hourAndMinute + ) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow 2.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow 2.swift new file mode 100644 index 00000000..7cbf60f2 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow 2.swift @@ -0,0 +1,64 @@ +// +// 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 AddTaskTriggerView { + + struct TriggerTypeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + Picker( + L10n.type, + selection: $taskTriggerInfo.type + ) { + ForEach(TaskTriggerType.allCases, id: \.self) { type in + Text(type.displayTitle) + .tag(type as TaskTriggerType?) + } + } + .onChange(of: taskTriggerInfo.type) { newType in + resetValuesForNewType(newType: newType) + } + } + + private func resetValuesForNewType(newType: TaskTriggerType?) { + taskTriggerInfo.type = newType + let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks + + switch newType { + case .daily: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + case .weekly: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = defaultDayOfWeek + taskTriggerInfo.intervalTicks = nil + case .interval: + taskTriggerInfo.intervalTicks = defaultIntervalTicks + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + case .startup: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + default: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + } + + taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift new file mode 100644 index 00000000..7cbf60f2 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift @@ -0,0 +1,64 @@ +// +// 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 AddTaskTriggerView { + + struct TriggerTypeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + Picker( + L10n.type, + selection: $taskTriggerInfo.type + ) { + ForEach(TaskTriggerType.allCases, id: \.self) { type in + Text(type.displayTitle) + .tag(type as TaskTriggerType?) + } + } + .onChange(of: taskTriggerInfo.type) { newType in + resetValuesForNewType(newType: newType) + } + } + + private func resetValuesForNewType(newType: TaskTriggerType?) { + taskTriggerInfo.type = newType + let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks + + switch newType { + case .daily: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + case .weekly: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = defaultDayOfWeek + taskTriggerInfo.intervalTicks = nil + case .interval: + taskTriggerInfo.intervalTicks = defaultIntervalTicks + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + case .startup: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + default: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + } + + taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift new file mode 100644 index 00000000..25b16299 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift @@ -0,0 +1,23 @@ +// +// 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 EditServerTaskView { + + struct DetailsSection: View { + + let category: String + + var body: some View { + Section(L10n.details) { + LabeledContent(L10n.category, value: category) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection 2.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection 2.swift new file mode 100644 index 00000000..59086c4b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection 2.swift @@ -0,0 +1,24 @@ +// +// 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 EditServerTaskView { + + struct LastErrorSection: View { + + let message: String + + var body: some View { + Section(L10n.errorDetails) { + Text(message) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift new file mode 100644 index 00000000..59086c4b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift @@ -0,0 +1,24 @@ +// +// 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 EditServerTaskView { + + struct LastErrorSection: View { + + let message: String + + var body: some View { + Section(L10n.errorDetails) { + Text(message) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift new file mode 100644 index 00000000..5de36b73 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension EditServerTaskView { + + struct LastRunSection: View { + + @CurrentDate + private var currentDate: Date + + let status: TaskCompletionStatus + let endTime: Date + + var body: some View { + Section(L10n.lastRun) { + + LabeledContent( + L10n.status, + value: status.displayTitle + ) + + LabeledContent(L10n.executed, value: endTime, format: .lastSeen) + .id(currentDate) + .monospacedDigit() + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift new file mode 100644 index 00000000..5506a826 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift @@ -0,0 +1,68 @@ +// +// 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 +// + +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension EditServerTaskView { + + struct ProgressSection: View { + + @ObservedObject + var observer: ServerTaskObserver + + var body: some View { + if observer.task.state == .running || observer.task.state == .cancelling { + Section(L10n.progress) { + if let status = observer.task.state { + LabeledContent( + L10n.status, + value: status.displayTitle + ) + } + + if let currentProgressPercentage = observer.task.currentProgressPercentage { + LabeledContent( + L10n.progress, + value: currentProgressPercentage / 100, + format: .percent.precision( + .fractionLength(1) + ) + ) + .monospacedDigit() + } + + Button { + observer.send(.stop) + } label: { + HStack { + Text(L10n.stop) + + Spacer() + + Image(systemName: "stop.fill") + } + } + .foregroundStyle(.red) + } + } else { + Button(L10n.run) { + observer.send(.start) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift new file mode 100644 index 00000000..547ae5ab --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift @@ -0,0 +1,65 @@ +// +// 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 EditServerTaskView { + + struct TriggersSection: View { + + @Router + private var router + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingDeleteConfirmation: Bool = false + @State + private var selectedTrigger: TaskTriggerInfo? + + var body: some View { + Section(L10n.triggers) { + if let triggers = observer.task.triggers, triggers.isNotEmpty { + ForEach(triggers, id: \.self) { trigger in + TriggerRow(taskTriggerInfo: trigger) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + selectedTrigger = trigger + isPresentingDeleteConfirmation = true + } label: { + Label(L10n.delete, systemImage: "trash") + } + .tint(.red) + } + } + } else { + Button(L10n.add) { + router.route(to: .addServerTaskTrigger(observer: observer)) + } + } + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let selectedTrigger { + observer.send(.removeTrigger(selectedTrigger)) + } + } + } message: { + Text(L10n.deleteSelectedConfirmation) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift new file mode 100644 index 00000000..a45b92a9 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift @@ -0,0 +1,90 @@ +// +// 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 EditServerTaskView { + + struct TriggerRow: View { + + let taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + HStack { + VStack(alignment: .leading) { + + Text(triggerDisplayText(for: taskTriggerInfo.type)) + .fontWeight(.semibold) + + Group { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + Text( + L10n.timeLimitLabelWithValue( + ServerTicks(maxRuntimeTicks) + .seconds.formatted(.hourMinute) + ) + ) + } else { + Text(L10n.noRuntimeLimit) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: (taskTriggerInfo.type ?? .startup).systemImage) + .fontWeight(.bold) + .foregroundStyle(.secondary) + } + } + + // MARK: - Trigger Display Text + + private func triggerDisplayText(for triggerType: TaskTriggerType?) -> String { + + guard let triggerType else { return L10n.unknown } + + switch triggerType { + case .daily: + if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { + return L10n.itemAtItem( + triggerType.displayTitle, + ServerTicks(timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) + ) + } + case .weekly: + if let dayOfWeek = taskTriggerInfo.dayOfWeek, + let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks + { + return L10n.itemAtItem( + dayOfWeek.rawValue.capitalized, + ServerTicks(timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) + ) + } + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + return L10n.everyInterval( + ServerTicks(intervalTicks) + .seconds.formatted(.hourMinute) + ) + } + case .startup: + return triggerType.displayTitle + } + + return L10n.unknown + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift new file mode 100644 index 00000000..e05a087c --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift @@ -0,0 +1,86 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import JellyfinAPI +import SwiftUI + +struct EditServerTaskView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + var observer: ServerTaskObserver + + // MARK: - Trigger Variables + + @State + private var selectedTrigger: TaskTriggerInfo? + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + List { + ListTitleSection( + observer.task.name ?? L10n.unknown, + description: observer.task.description + ) + + ProgressSection(observer: observer) + + if let category = observer.task.category { + DetailsSection(category: category) + } + + if let lastExecutionResult = observer.task.lastExecutionResult { + if let status = lastExecutionResult.status, let endTime = lastExecutionResult.endTimeUtc { + LastRunSection(status: status, endTime: endTime) + } + + if let errorMessage = lastExecutionResult.errorMessage { + LastErrorSection(message: errorMessage) + } + } + + TriggersSection(observer: observer) + } + .animation(.linear(duration: 0.2), value: observer.state) + .animation(.linear(duration: 0.1), value: observer.task.state) + .animation(.linear(duration: 0.1), value: observer.task.triggers) + .navigationTitle(L10n.task) + .topBarTrailing { + + if observer.backgroundStates.contains(.updatingTriggers) { + ProgressView() + } + + if let triggers = observer.task.triggers, triggers.isNotEmpty { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: .addServerTaskTrigger(observer: observer)) + } + .buttonStyle(.toolbarPill) + } + } + .onReceive(observer.events) { event in + switch event { + case let .error(eventError): + error = eventError + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift new file mode 100644 index 00000000..46fadbff --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension ServerTasksView { + + struct DestructiveServerTask: View { + + @State + private var isPresented: Bool = false + + let title: String + let systemName: String + let message: String + let action: () -> Void + + // MARK: - Body + + var body: some View { + Button(role: .destructive) { + isPresented = true + } label: { + HStack { + Text(title) + .fontWeight(.semibold) + + Spacer() + + Image(systemName: systemName) + .fontWeight(.bold) + } + } + .confirmationDialog( + title, + isPresented: $isPresented, + titleVisibility: .visible + ) { + Button(title, role: .destructive, action: action) + } message: { + Text(message) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift new file mode 100644 index 00000000..40e16226 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift @@ -0,0 +1,125 @@ +// +// 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 ServerTasksView { + + struct ServerTaskRow: View { + + @CurrentDate + private var currentDate: Date + + @Router + private var router + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingConfirmation = false + + // MARK: - Task Details Section + + @ViewBuilder + private var taskView: some View { + VStack(alignment: .leading, spacing: 4) { + + Text(observer.task.name ?? L10n.unknown) + .fontWeight(.semibold) + + taskResultView + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Task Status View + + @ViewBuilder + private var taskResultView: some View { + if observer.state == .running { + Text(L10n.running) + } else if observer.task.state == .cancelling { + Text(L10n.cancelling) + } else { + if let taskEndTime = observer.task.lastExecutionResult?.endTimeUtc { + Text(L10n.lastRunTime(Date.RelativeFormatStyle(presentation: .numeric, unitsStyle: .narrow).format(taskEndTime))) + .id(currentDate) + .monospacedDigit() + } else { + Text(L10n.neverRun) + } + + if let status = observer.task.lastExecutionResult?.status, status != .completed { + Label( + status.displayTitle, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + .foregroundStyle(.orange) + .fontWeight(.semibold) + } + } + } + + @ViewBuilder + var body: some View { + Button { + isPresentingConfirmation = true + } label: { + HStack { + taskView + + Spacer() + + if observer.state == .running { + ProgressView(value: (observer.task.currentProgressPercentage ?? 0) / 100) + .progressViewStyle(.gauge) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .frame(width: 25, height: 25) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundStyle(.secondary) + } + } + .animation(.linear(duration: 0.1), value: observer.state) + .foregroundStyle(.primary, .secondary) + .confirmationDialog( + observer.task.name ?? L10n.unknown, + isPresented: $isPresentingConfirmation, + titleVisibility: .visible + ) { + Group { + if observer.state == .running { + Button(L10n.stop) { + observer.send(.stop) + } + } else { + Button(L10n.run) { + observer.send(.start) + } + } + } + .disabled(observer.task.state == .cancelling) + + Button(L10n.edit) { + router.route(to: .editServerTask(observer: observer)) + } + } message: { + if let description = observer.task.description { + Text(description) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift new file mode 100644 index 00000000..fe1ce50e --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift @@ -0,0 +1,104 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: refactor after socket implementation + +struct ServerTasksView: View { + + @Router + private var router + + @StateObject + private var viewModel = ServerTasksViewModel() + + private let timer = Timer.publish(every: 5, on: .main, in: .common) + .autoconnect() + + // MARK: - Server Function Buttons + + @ViewBuilder + private var serverFunctions: some View { + DestructiveServerTask( + title: L10n.restartServer, + systemName: "arrow.clockwise", + message: L10n.restartWarning + ) { + viewModel.send(.restartApplication) + } + + DestructiveServerTask( + title: L10n.shutdownServer, + systemName: "power", + message: L10n.shutdownWarning + ) { + viewModel.send(.shutdownApplication) + } + } + + // MARK: - Body + + @ViewBuilder + private var contentView: some View { + List { + + ListTitleSection( + L10n.tasks, + description: L10n.tasksDescription + ) { + UIApplication.shared.open(.jellyfinDocsTasks) + } + + Section(L10n.server) { + serverFunctions + } + + ForEach(viewModel.tasks.keys, id: \.self) { category in + Section(category) { + ForEach(viewModel.tasks[category] ?? []) { task in + ServerTaskRow(observer: task) + } + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refreshTasks) + } + } + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.tasks) + .onFirstAppear { + viewModel.send(.refreshTasks) + } + .onReceive(timer) { _ in + viewModel.send(.getTasks) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift new file mode 100644 index 00000000..7c80222e --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift @@ -0,0 +1,132 @@ +// +// 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 AddServerUserView: View { + + private enum Field { + case username + case password + case confirmPassword + } + + @FocusState + private var focusedfield: Field? + + @Router + private var router + + @State + private var confirmPassword: String = "" + @State + private var password: String = "" + @State + private var username: String = "" + + @StateObject + private var viewModel = AddServerUserViewModel() + + private var isValid: Bool { + username.isNotEmpty && password == confirmPassword + } + + // MARK: - Body + + var body: some View { + List { + + Section { + TextField(L10n.username, text: $username) { + focusedfield = .password + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .username) + .disabled(viewModel.state == .addingUser) + } header: { + Text(L10n.username) + } footer: { + if username.isEmpty { + Label(L10n.usernameRequired, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + Section(L10n.password) { + SecureField( + L10n.password, + text: $password, + maskToggle: .enabled + ) + .onSubmit { + focusedfield = .confirmPassword + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .password) + .disabled(viewModel.state == .addingUser) + } + + Section { + SecureField( + L10n.confirmPassword, + text: $confirmPassword, + maskToggle: .enabled + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedfield, equals: .confirmPassword) + .disabled(viewModel.state == .addingUser) + } header: { + Text(L10n.confirmPassword) + } footer: { + if password != confirmPassword { + Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + .animation(.linear(duration: 0.1), value: isValid) + .interactiveDismissDisabled(viewModel.state == .addingUser) + .navigationTitle(L10n.newUser) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton(disabled: viewModel.state != .initial) { + router.dismiss() + } + .onFirstAppear { + focusedfield = .username + } + .onReceive(viewModel.events) { event in + switch event { + case let .created(newUser): + UIDevice.feedback(.success) + Notifications[.didAddServerUser].post(newUser) + router.dismiss() + } + } + .topBarTrailing { + if viewModel.state == .addingUser { + ProgressView() + Button(L10n.cancel) { + viewModel.cancel() + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + viewModel.add(username: username, password: password) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + } + .errorMessage($viewModel.error) { + focusedfield = .username + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift new file mode 100644 index 00000000..a21a7f4d --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift @@ -0,0 +1,135 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ServerUserDetailsView: View { + + // MARK: - Current Date + + @CurrentDate + private var currentDate: Date + + // MARK: - State, Observed, & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: ServerUserAdminViewModel + + @StateObject + private var profileViewModel: UserProfileImageViewModel + + // MARK: - Dialog State + + @State + private var username: String + @State + private var isPresentingUsername = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(user: UserDto) { + self._viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) + self._profileViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: user)) + self.username = user.name ?? "" + } + + // MARK: - Body + + var body: some View { + List { + UserProfileHeroImage( + user: viewModel.user, + source: viewModel.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 150 + ) + ) { + router.route(to: .userProfileImage(viewModel: profileViewModel)) + } onDelete: { + profileViewModel.send(.delete) + } + + Section { + ChevronButton( + L10n.username, + subtitle: viewModel.user.name, + description: nil + ) { + TextField(L10n.username, text: $username) + } onSave: { + viewModel.send(.updateUsername(username)) + isPresentingUsername = false + } onCancel: { + username = viewModel.user.name ?? "" + isPresentingUsername = false + } + + ChevronButton(L10n.permissions) { + router.route(to: .userPermissions(viewModel: viewModel)) + } + + if let userId = viewModel.user.id { + ChevronButton(L10n.password) { + router.route(to: .resetUserPasswordAdmin(userID: userId)) + } + ChevronButton(L10n.quickConnect) { + router.route(to: .quickConnectAuthorize(user: viewModel.user)) + } + } + } + + Section(L10n.access) { + ChevronButton(L10n.devices) { + router.route(to: .userDeviceAccess(viewModel: viewModel)) + } + ChevronButton(L10n.liveTV) { + router.route(to: .userLiveTVAccess(viewModel: viewModel)) + } + ChevronButton(L10n.media) { + router.route(to: .userMediaAccess(viewModel: viewModel)) + } + } + + Section(L10n.parentalControls) { + ChevronButton(L10n.parentalRating) { + router.route(to: .userParentalRatings(viewModel: viewModel)) + } + ChevronButton(L10n.accessSchedules) { + router.route(to: .userEditAccessSchedules(viewModel: viewModel)) + } + ChevronButton(L10n.accessTags) { + router.route(to: .userEditAccessTags(viewModel: viewModel)) + } + } + } + .navigationTitle(L10n.user) + .onAppear { + viewModel.send(.refresh) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + username = viewModel.user.name ?? "" + case .updated: + break + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift new file mode 100644 index 00000000..416b9e02 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift @@ -0,0 +1,188 @@ +// +// 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 + +struct AddAccessScheduleView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Access Schedule Variables + + @State + private var tempPolicy: UserPolicy + @State + private var selectedDay: DynamicDayOfWeek = .everyday + @State + private var startTime: Date = Calendar.current.startOfDay(for: Date()) + @State + private var endTime: Date = Calendar.current.startOfDay(for: Date()).addingTimeInterval(+3600) + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy! + } + + private var isValidRange: Bool { + startTime < endTime + } + + private var newSchedule: AccessSchedule? { + guard isValidRange else { return nil } + + let calendar = Calendar.current + let startComponents = calendar.dateComponents([.hour, .minute], from: startTime) + let endComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + guard let startHour = startComponents.hour, + let startMinute = startComponents.minute, + let endHour = endComponents.hour, + let endMinute = endComponents.minute + else { + return nil + } + + // AccessSchedule Hours are formatted as 23.5 == 11:30pm or 8.25 == 8:15am + let startDouble = Double(startHour) + Double(startMinute) / 60.0 + let endDouble = Double(endHour) + Double(endMinute) / 60.0 + + // AccessSchedule should have valid Start & End Hours + let newSchedule = AccessSchedule( + dayOfWeek: selectedDay, + endHour: endDouble, + startHour: startDouble, + userID: viewModel.user.id + ) + + return newSchedule + } + + private var isDuplicateSchedule: Bool { + guard let newSchedule, let existingSchedules = viewModel.user.policy?.accessSchedules else { + return false + } + + return existingSchedules.contains { other in + other.dayOfWeek == selectedDay && + other.startHour == newSchedule.startHour && + other.endHour == newSchedule.endHour + } + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.addAccessSchedule.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + ProgressView() + } + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + saveSchedule() + } + .buttonStyle(.toolbarPill) + .disabled(!isValidRange) + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + Form { + Section(L10n.dayOfWeek) { + Picker(L10n.dayOfWeek, selection: $selectedDay) { + ForEach(DynamicDayOfWeek.allCases, id: \.self) { day in + + if day == .everyday { + Divider() + } + + Text(day.displayTitle).tag(day) + } + } + } + + Section(L10n.startTime) { + DatePicker(L10n.startTime, selection: $startTime, displayedComponents: .hourAndMinute) + } + + Section { + DatePicker(L10n.endTime, selection: $endTime, displayedComponents: .hourAndMinute) + } header: { + Text(L10n.endTime) + } footer: { + if !isValidRange { + Label(L10n.accessScheduleInvalidTime, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + + if isDuplicateSchedule { + Label(L10n.scheduleAlreadyExists, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + } + + // MARK: - Save Schedule + + private func saveSchedule() { + + guard isValidRange, let newSchedule else { + error = JellyfinAPIError(L10n.accessScheduleInvalidTime) + return + } + + guard !isDuplicateSchedule else { + error = JellyfinAPIError(L10n.scheduleAlreadyExists) + return + } + + tempPolicy.accessSchedules = tempPolicy.accessSchedules + .appendedOrInit(newSchedule) + + viewModel.send(.updatePolicy(tempPolicy)) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift new file mode 100644 index 00000000..65394a96 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift @@ -0,0 +1,108 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditAccessScheduleView { + + struct EditAccessScheduleRow: View { + + // MARK: - Environment Variables + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + // MARK: - Schedule Variable + + let schedule: AccessSchedule + + // MARK: - Schedule Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + rowContent + } + .foregroundStyle(.primary, .secondary) + .swipeActions { + Button(L10n.delete, systemImage: "trash", action: onDelete) + .tint(.red) + } + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + if let dayOfWeek = schedule.dayOfWeek { + Text(dayOfWeek.displayTitle) + .fontWeight(.semibold) + } + + Group { + if let startHour = schedule.startHour { + LabeledContent( + L10n.startTime, + value: doubleToTimeString(startHour) + ) + } + + if let endHour = schedule.endHour { + LabeledContent( + L10n.endTime, + value: doubleToTimeString(endHour) + ) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary, + .secondary + ) + + Spacer() + + ListRowCheckbox() + } + } + + // MARK: - Convert Double to Date + + private func doubleToTimeString(_ double: Double) -> String { + let startHours = Int(double) + let startMinutes = Int(double.truncatingRemainder(dividingBy: 1) * 60) + + var dateComponents = DateComponents() + dateComponents.hour = startHours + dateComponents.minute = startMinutes + + let calendar = Calendar.current + + guard let date = calendar.date(from: dateComponents) else { + return .emptyRuntime + } + + let formatter = DateFormatter() + formatter.timeStyle = .short + + return formatter.string(from: date) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift new file mode 100644 index 00000000..962da121 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift @@ -0,0 +1,227 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct EditAccessScheduleView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Policy Variable + + @State + private var selectedSchedules: Set = [] + + // MARK: - Dialog States + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + + // MARK: - Editing State + + @State + private var isEditing: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.accessSchedules.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(L10n.cancel) { + isEditing.toggle() + selectedSchedules.removeAll() + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedSchedules.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || viewModel.user.policy?.accessSchedules == [] + ) { + Button(L10n.add, systemImage: "plus") { + router.route(to: .userAddAccessSchedule(viewModel: viewModel)) + } + + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + } + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedSchedulesConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteScheduleConfirmationActions + } message: { + Text(L10n.deleteItemConfirmation) + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + ListTitleSection( + L10n.accessSchedules.localizedCapitalized, + description: L10n.accessSchedulesDescription + ) { + UIApplication.shared.open(.jellyfinDocsManagingUsers) + } + + if viewModel.user.policy?.accessSchedules == [] { + Button(L10n.add) { + router.route(to: .userAddAccessSchedule(viewModel: viewModel)) + } + } else { + ForEach(viewModel.user.policy?.accessSchedules ?? [], id: \.self) { schedule in + EditAccessScheduleRow(schedule: schedule) { + if isEditing { + selectedSchedules.toggle(value: schedule) + } + } onDelete: { + selectedSchedules = [schedule] + isPresentingDeleteConfirmation = true + } + .isEditing(isEditing) + .isSelected(selectedSchedules.contains(schedule)) + } + } + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + + let isAllSelected: Bool = selectedSchedules.count == viewModel.user.policy?.accessSchedules?.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedSchedules = [] + } else { + selectedSchedules = Set(viewModel.user.policy?.accessSchedules ?? []) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - Delete Selected Schedules Confirmation Actions + + @ViewBuilder + private var deleteSelectedSchedulesConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + + var tempPolicy: UserPolicy = viewModel.user.policy! + + if selectedSchedules.isNotEmpty { + tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { !selectedSchedules.contains($0) + } + viewModel.send(.updatePolicy(tempPolicy)) + isEditing = false + selectedSchedules.removeAll() + } + } + } + + // MARK: - Delete Schedule Confirmation Actions + + @ViewBuilder + private var deleteScheduleConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + + var tempPolicy: UserPolicy = viewModel.user.policy! + + if let scheduleToDelete = selectedSchedules.first, + selectedSchedules.count == 1 + { + tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { + $0 != scheduleToDelete + } + viewModel.send(.updatePolicy(tempPolicy)) + isEditing = false + selectedSchedules.removeAll() + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift new file mode 100644 index 00000000..019ec8be --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift @@ -0,0 +1,149 @@ +// +// 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 + +struct AddServerUserAccessTagsView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + @StateObject + private var tagViewModel: TagEditorViewModel + + // MARK: - Access Tag Variables + + @State + private var tempPolicy: UserPolicy + @State + private var tempTag: String = "" + @State + private var access: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Name is Valid + + private var isValid: Bool { + tempTag.isNotEmpty && !tagIsDuplicate + } + + // MARK: - Tag is Already Blocked/Allowed + + private var tagIsDuplicate: Bool { + viewModel.user.policy!.blockedTags!.contains(tempTag) || viewModel.user.policy!.allowedTags!.contains(tempTag) + } + + // MARK: - Tag Already Exists on Jellyfin + + private var tagAlreadyExists: Bool { + tagViewModel.trie.contains(key: tempTag.localizedLowercase) + } + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy! + self._tagViewModel = StateObject(wrappedValue: TagEditorViewModel(item: .init())) + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.addAccessTag.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + ProgressView() + } + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + if access { + tempPolicy.allowedTags = tempPolicy.allowedTags + .appendedOrInit(tempTag) + } else { + tempPolicy.blockedTags = tempPolicy.blockedTags + .appendedOrInit(tempTag) + } + + viewModel.send(.updatePolicy(tempPolicy)) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + } + .onFirstAppear { + tagViewModel.send(.load) + } + .onChange(of: tempTag) { _ in + if !tagViewModel.backgroundStates.contains(.loading) { + tagViewModel.send(.search(tempTag)) + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .onReceive(tagViewModel.events) { event in + switch event { + case .updated: + break + case .loaded: + tagViewModel.send(.search(tempTag)) + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + Form { + TagInput( + access: $access, + tag: $tempTag, + tagIsDuplicate: tagIsDuplicate, + tagAlreadyExists: tagAlreadyExists + ) + + SearchResultsSection( + tag: $tempTag, + tags: tagViewModel.matches, + isSearching: tagViewModel.backgroundStates.contains(.searching) + ) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift new file mode 100644 index 00000000..80c5a0bc --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift @@ -0,0 +1,72 @@ +// +// 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 AddServerUserAccessTagsView { + + struct SearchResultsSection: View { + + // MARK: - Element Variables + + @Binding + var tag: String + + // MARK: - Element Search Variables + + let tags: [String] + let isSearching: Bool + + // MARK: - Body + + var body: some View { + if tag.isNotEmpty { + Section { + if tags.isNotEmpty { + resultsView + } else if !isSearching { + noResultsView + } + } header: { + HStack { + Text(L10n.existingItems) + + if isSearching { + ProgressView() + } else { + Text("-") + + Text(tags.count, format: .number) + } + } + } + .animation(.linear(duration: 0.2), value: tags) + } + } + + // MARK: - No Results View + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + // MARK: - Results View + + private var resultsView: some View { + ForEach(tags, id: \.self) { result in + Button(result) { + tag = result + } + .foregroundStyle(.primary) + .disabled(tag == result) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift new file mode 100644 index 00000000..0e8c8a6f --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift @@ -0,0 +1,91 @@ +// +// 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 AddServerUserAccessTagsView { + + struct TagInput: View { + + // MARK: - Element Variables + + @FocusState + private var isTagFocused: Bool + + @Binding + var access: Bool + @Binding + var tag: String + + let tagIsDuplicate: Bool + let tagAlreadyExists: Bool + + // MARK: - Body + + var body: some View { + Section { + Picker(L10n.access, selection: $access) { + Text(L10n.allowed).tag(true) + Text(L10n.blocked).tag(false) + } + } header: { + Text(L10n.access) + } footer: { + LearnMoreButton(L10n.accessTags) { + LabeledContent( + L10n.allowed, + value: L10n.accessTagAllowDescription + ) + + LabeledContent( + L10n.blocked, + value: L10n.accessTagBlockDescription + ) + } + } + + Section { + TextField(L10n.name, text: $tag) + .autocorrectionDisabled() + .focused($isTagFocused) + } footer: { + if tag.isEmpty { + Label( + L10n.required, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else if tagIsDuplicate { + Label( + L10n.accessTagAlreadyExists, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else { + if tagAlreadyExists { + Label( + L10n.existsOnServer, + systemImage: "checkmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .green)) + } else { + Label( + L10n.willBeCreatedOnServer, + systemImage: "checkmark.seal.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) + } + } + } + .onFirstAppear { + isTagFocused = true + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift new file mode 100644 index 00000000..9f1820a3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift @@ -0,0 +1,48 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditServerUserAccessTagsView { + + struct EditAccessTagRow: View { + + // MARK: - Metadata Variables + + let tag: String + + // MARK: - Row Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + HStack { + Text(tag) + .frame(maxWidth: .infinity, alignment: .leading) + + ListRowCheckbox() + } + } + .foregroundStyle(.primary) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift new file mode 100644 index 00000000..67b2356a --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift @@ -0,0 +1,247 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct EditServerUserAccessTagsView: View { + + private struct TagWithAccess: Hashable { + let tag: String + let access: Bool + } + + // MARK: - Observed, State, & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Dialog States + + @State + private var isPresentingDeleteConfirmation = false + + // MARK: - Editing States + + @State + private var selectedTags: Set = [] + @State + private var isEditing: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + private var hasTags: Bool { + viewModel.user.policy?.blockedTags?.isEmpty == true && + viewModel.user.policy?.allowedTags?.isEmpty == true + } + + private var allowedTags: [TagWithAccess] { + viewModel.user.policy?.allowedTags? + .sorted() + .map { TagWithAccess(tag: $0, access: true) } ?? [] + } + + private var blockedTags: [TagWithAccess] { + viewModel.user.policy?.blockedTags? + .sorted() + .map { TagWithAccess(tag: $0, access: false) } ?? [] + } + + // MARK: - Initializera + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationTitle(L10n.accessTags) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(L10n.cancel) { + isEditing = false + UIDevice.impact(.light) + selectedTags.removeAll() + } + .buttonStyle(.toolbarPill) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedTags.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || hasTags + ) { + Button(L10n.add, systemImage: "plus") { + router.route(to: .userAddAccessTag(viewModel: viewModel)) + } + + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + default: + break + } + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteSelectedConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .errorMessage($error) + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + @ViewBuilder + private func makeRow(tag: TagWithAccess) -> some View { + EditAccessTagRow(tag: tag.tag) { + if isEditing { + selectedTags.toggle(value: tag) + } + } onDelete: { + selectedTags = [tag] + isPresentingDeleteConfirmation = true + } + .isEditing(isEditing) + .isSelected(selectedTags.contains(tag)) + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + List { + ListTitleSection( + L10n.accessTags, + description: L10n.accessTagsDescription + ) { + UIApplication.shared.open(.jellyfinDocsManagingUsers) + } + + if blockedTags.isEmpty, allowedTags.isEmpty { + Button(L10n.add) { + router.route(to: .userAddAccessTag(viewModel: viewModel)) + } + } else { + if allowedTags.isNotEmpty { + Section { + DisclosureGroup(L10n.allowed) { + ForEach( + allowedTags, + id: \.self, + content: makeRow + ) + } + } + } + if blockedTags.isNotEmpty { + Section { + DisclosureGroup(L10n.blocked) { + ForEach( + blockedTags, + id: \.self, + content: makeRow + ) + } + } + } + } + } + } + + // MARK: - Select/Remove All Button + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected = selectedTags.count == blockedTags.count + allowedTags.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + selectedTags = isAllSelected ? [] : Set(blockedTags + allowedTags) + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + } + + // MARK: - Delete Selected Confirmation Actions + + @ViewBuilder + private var deleteSelectedConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + var tempPolicy = policy + + for tag in selectedTags { + if tag.access { + tempPolicy.allowedTags?.removeAll(equalTo: tag.tag) + } else { + tempPolicy.blockedTags?.removeAll(equalTo: tag.tag) + } + } + + viewModel.send(.updatePolicy(tempPolicy)) + selectedTags.removeAll() + isEditing = false + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift new file mode 100644 index 00000000..5fec859a --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift @@ -0,0 +1,144 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ServerUserMediaAccessView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Policy Variable + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + self.tempPolicy = policy + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.mediaAccess) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onFirstAppear { + viewModel.send(.loadLibraries()) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + accessView + deletionView + } + } + + // MARK: - Media Access View + + @ViewBuilder + var accessView: some View { + Section(L10n.access) { + Toggle( + L10n.enableAllLibraries, + isOn: $tempPolicy.enableAllFolders.coalesce(false) + ) + } + + if tempPolicy.enableAllFolders == false { + Section { + ForEach(viewModel.libraries, id: \.id) { library in + Toggle( + library.displayTitle, + isOn: $tempPolicy.enabledFolders + .coalesce([]) + .contains(library.id!) + ) + } + } + } + } + + // MARK: - Media Deletion View + + @ViewBuilder + var deletionView: some View { + Section(L10n.deletion) { + Toggle( + L10n.enableAllLibraries, + isOn: $tempPolicy.enableContentDeletion.coalesce(false) + ) + } + + if tempPolicy.enableContentDeletion == false { + Section { + ForEach( + viewModel.libraries.filter { $0.collectionType != .boxsets }, + id: \.id + ) { library in + Toggle( + library.displayTitle, + isOn: $tempPolicy.enableContentDeletionFromFolders + .coalesce([]) + .contains(library.id!) + ) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift new file mode 100644 index 00000000..8af361ba --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift @@ -0,0 +1,125 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ServerUserDeviceAccessView: View { + + // MARK: - Current Date + + @CurrentDate + private var currentDate: Date + + // MARK: - State & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: ServerUserAdminViewModel + @StateObject + private var devicesViewModel = DevicesViewModel() + + // MARK: - State Variables + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + self.tempPolicy = policy + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.deviceAccess) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .onFirstAppear { + devicesViewModel.refresh() + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + InsetGroupedListHeader { + Toggle( + L10n.enableAllDevices, + isOn: $tempPolicy.enableAllDevices.coalesce(false) + ) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if tempPolicy.enableAllDevices == false { + Section { + ForEach(devicesViewModel.devices, id: \.self) { device in + DevicesView.DeviceRow(device: device) { + if let index = tempPolicy.enabledDevices?.firstIndex(of: device.id!) { + tempPolicy.enabledDevices?.remove(at: index) + } else { + if tempPolicy.enabledDevices == nil { + tempPolicy.enabledDevices = [] + } + tempPolicy.enabledDevices?.append(device.id!) + } + } + .isEditing(true) + .isSelected(tempPolicy.enabledDevices?.contains(device.id ?? "") == true) + } + } + } + } + .listStyle(.plain) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift new file mode 100644 index 00000000..03053cde --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift @@ -0,0 +1,101 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ServerUserLiveTVAccessView: View { + + // MARK: - Current Date + + @CurrentDate + private var currentDate: Date + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Policy Variable + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + self.tempPolicy = policy + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.liveTVAccess.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + Section(L10n.access) { + Toggle( + L10n.liveTVAccess, + isOn: $tempPolicy.enableLiveTvAccess.coalesce(false) + ) + Toggle( + L10n.liveTVRecordingManagement, + isOn: $tempPolicy.enableLiveTvManagement.coalesce(false) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift new file mode 100644 index 00000000..6ee63243 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift @@ -0,0 +1,180 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ServerUserParentalRatingView: View { + + // MARK: - Observed, State, & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: ServerUserAdminViewModel + @StateObject + private var parentalRatingsViewModel: ParentalRatingsViewModel + + // MARK: - Policy Variable + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + self._parentalRatingsViewModel = StateObject(wrappedValue: ParentalRatingsViewModel(initialValue: [])) + + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + self.tempPolicy = policy + } + + // MARK: - Body + + var body: some View { + List { + maxParentalRatingsView + + blockUnratedItemsView + } + .navigationTitle(L10n.parentalRating.localizedCapitalized) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onFirstAppear { + parentalRatingsViewModel.refresh() + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + + // MARK: - Maximum Parental Ratings View + + @ViewBuilder + private var maxParentalRatingsView: some View { + Section { + Picker(L10n.parentalRating, selection: $tempPolicy.maxParentalRating) { + ForEach( + reducedParentalRatings(), + id: \.value + ) { rating in + Text(rating.name ?? L10n.unknown) + .tag(rating.value) + } + } + } header: { + Text(L10n.maxParentalRating) + } footer: { + VStack(alignment: .leading) { + Text(L10n.maxParentalRatingDescription) + + LearnMoreButton( + L10n.parentalRating, + content: parentalRatingLabeledContent + ) + } + } + } + + // MARK: - Block Unrated Items View + + @ViewBuilder + private var blockUnratedItemsView: some View { + Section { + ForEach(UnratedItem.allCases.sorted(using: \.displayTitle), id: \.self) { item in + Toggle( + item.displayTitle, + isOn: $tempPolicy.blockUnratedItems + .coalesce([]) + .contains(item) + ) + } + } header: { + Text(L10n.blockUnratedItems) + } footer: { + Text(L10n.blockUnratedItemsDescription) + } + } + + private func reducedParentalRatings() -> [ParentalRating] { + [ParentalRating(name: L10n.none, value: nil)] + + parentalRatingsViewModel.value.grouped { $0.value ?? 0 } + .map { key, group in + if key < 100 { + if key == 0 { + return ParentalRating(name: L10n.allAudiences, value: key) + } else { + return ParentalRating(name: L10n.agesGroup(key), value: key) + } + } else { + let name = group + .compactMap(\.name) + .sorted() + .joined(separator: " / ") + + return ParentalRating(name: name, value: key) + } + } + .sorted(using: \.value) + } + + // MARK: - Parental Rating Learn More + + @LabeledContentBuilder + private func parentalRatingLabeledContent() -> AnyView { + let reducedRatings = reducedParentalRatings() + let groupedRatings = parentalRatingsViewModel.value.grouped { $0.value ?? 0 } + + ForEach(groupedRatings.keys.sorted(), id: \.self) { key in + if let matchingRating = reducedRatings.first(where: { $0.value == key }) { + let name = groupedRatings[key]? + .compactMap(\.name) + .sorted() + .joined(separator: "\n") ?? L10n.none + + LabeledContent(matchingRating.name ?? L10n.none) { + Text(name) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift new file mode 100644 index 00000000..0634783b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift @@ -0,0 +1,66 @@ +// +// 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 ServerUserPermissionsView { + + struct ExternalAccessSection: View { + + @Binding + var policy: UserPolicy + + // MARK: - Body + + var body: some View { + Section(L10n.remoteConnections) { + + Toggle( + L10n.remoteConnections, + isOn: $policy.enableRemoteAccess.coalesce(false) + ) + + CaseIterablePicker( + L10n.maximumRemoteBitrate, + selection: $policy.remoteClientBitrateLimit.map( + getter: { MaxBitratePolicy(rawValue: $0) ?? .custom }, + setter: { $0.rawValue } + ) + ) + + if policy.remoteClientBitrateLimit != MaxBitratePolicy.unlimited.rawValue { + ChevronButton( + L10n.customBitrate, + subtitle: Text(policy.remoteClientBitrateLimit ?? 0, format: .bitRate), + description: L10n.enterCustomBitrate + ) { + MaxBitrateInput() + } + } + } + } + + // MARK: - Create Bitrate Input + + @ViewBuilder + private func MaxBitrateInput() -> some View { + let bitrateBinding = $policy.remoteClientBitrateLimit + .coalesce(0) + .map( + // Convert to Mbps + getter: { Double($0) / 1_000_000 }, + setter: { Int($0 * 1_000_000) } + ) + .min(0.001) // Minimum bitrate of 1 Kbps + + TextField(L10n.maximumBitrate, value: bitrateBinding, format: .number) + .keyboardType(.numbersAndPunctuation) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift new file mode 100644 index 00000000..f4b28cb2 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift @@ -0,0 +1,44 @@ +// +// 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 ServerUserPermissionsView { + + struct ManagementSection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.management) { + + Toggle( + L10n.administrator, + isOn: $policy.isAdministrator.coalesce(false) + ) + + Toggle( + L10n.collections, + isOn: $policy.enableCollectionManagement + ) + + Toggle( + L10n.subtitles, + isOn: $policy.enableSubtitleManagement + ) + + Toggle( + L10n.lyrics, + isOn: $policy.enableLyricManagement + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift new file mode 100644 index 00000000..c3b3b23e --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ServerUserPermissionsView { + + struct MediaPlaybackSection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.mediaPlayback) { + + Toggle( + L10n.mediaPlayback, + isOn: $policy.enableMediaPlayback.coalesce(false) + ) + + Toggle( + L10n.audioTranscoding, + isOn: $policy.enableAudioPlaybackTranscoding.coalesce(false) + ) + + Toggle( + L10n.videoTranscoding, + isOn: $policy.enableVideoPlaybackTranscoding.coalesce(false) + ) + + Toggle( + L10n.videoRemuxing, + isOn: $policy.enablePlaybackRemuxing.coalesce(false) + ) + + Toggle( + L10n.forceRemoteTranscoding, + isOn: $policy.isForceRemoteSourceTranscoding.coalesce(false) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift new file mode 100644 index 00000000..a0383afc --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift @@ -0,0 +1,34 @@ +// +// 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 ServerUserPermissionsView { + + struct PermissionSection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.permissions) { + + Toggle( + L10n.mediaDownloads, + isOn: $policy.enableContentDownloading.coalesce(false) + ) + + Toggle( + L10n.hideUserFromLoginScreen, + isOn: $policy.isHidden.coalesce(false) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift new file mode 100644 index 00000000..e505b661 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift @@ -0,0 +1,34 @@ +// +// 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 ServerUserPermissionsView { + + struct RemoteControlSection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.remoteControl) { + + Toggle( + L10n.controlOtherUsers, + isOn: $policy.enableRemoteControlOfOtherUsers.coalesce(false) + ) + + Toggle( + L10n.controlSharedDevices, + isOn: $policy.enableSharedDeviceControl.coalesce(false) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift new file mode 100644 index 00000000..2d6a6839 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift @@ -0,0 +1,146 @@ +// +// 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 ServerUserPermissionsView { + + struct SessionsSection: View { + + @Binding + var policy: UserPolicy + + // MARK: - Body + + var body: some View { + FailedLoginsView + MaxSessionsView + } + + // MARK: - Failed Login Selection View + + @ViewBuilder + private var FailedLoginsView: some View { + Section { + CaseIterablePicker( + L10n.maximumFailedLoginPolicy, + selection: $policy.loginAttemptsBeforeLockout + .coalesce(0) + .map( + getter: { LoginFailurePolicy(rawValue: $0) ?? .custom }, + setter: { $0.rawValue } + ) + ) + + if let loginAttempts = policy.loginAttemptsBeforeLockout, loginAttempts > 0 { + MaxFailedLoginsButton() + } + + } header: { + Text(L10n.sessions) + } footer: { + VStack(alignment: .leading) { + Text(L10n.maximumFailedLoginPolicyDescription) + + LearnMoreButton(L10n.maximumFailedLoginPolicy) { + LabeledContent( + L10n.lockedUsers, + value: L10n.maximumFailedLoginPolicyReenable + ) + LabeledContent( + L10n.unlimited, + value: L10n.unlimitedFailedLoginDescription + ) + LabeledContent( + L10n.default, + value: L10n.defaultFailedLoginDescription + ) + LabeledContent( + L10n.custom, + value: L10n.customFailedLoginDescription + ) + } + } + } + } + + // MARK: - Failed Login Selection Button + + @ViewBuilder + private func MaxFailedLoginsButton() -> some View { + ChevronButton( + L10n.customFailedLogins, + subtitle: Text(policy.loginAttemptsBeforeLockout ?? 1, format: .number), + description: L10n.enterCustomFailedLogins + ) { + TextField( + L10n.failedLogins, + value: $policy.loginAttemptsBeforeLockout + .coalesce(1) + .clamp(min: 1, max: 1000), + format: .number + ) + .keyboardType(.numberPad) + } + } + + // MARK: - Failed Login Validation + + @ViewBuilder + private var MaxSessionsView: some View { + Section { + CaseIterablePicker( + L10n.maximumSessionsPolicy, + selection: $policy.maxActiveSessions.map( + getter: { ActiveSessionsPolicy(rawValue: $0) ?? .custom }, + setter: { $0.rawValue } + ) + ) + + if policy.maxActiveSessions != ActiveSessionsPolicy.unlimited.rawValue { + MaxSessionsButton() + } + + } footer: { + VStack(alignment: .leading) { + Text(L10n.maximumConnectionsDescription) + + LearnMoreButton(L10n.maximumSessionsPolicy) { + LabeledContent( + L10n.unlimited, + value: L10n.unlimitedConnectionsDescription + ) + LabeledContent( + L10n.custom, + value: L10n.customConnectionsDescription + ) + } + } + } + } + + @ViewBuilder + private func MaxSessionsButton() -> some View { + ChevronButton( + L10n.customSessions, + subtitle: Text(policy.maxActiveSessions ?? 1, format: .number), + description: L10n.enterCustomMaxSessions + ) { + TextField( + L10n.maximumSessions, + value: $policy.maxActiveSessions + .coalesce(1) + .clamp(min: 1, max: 1000), + format: .number + ) + .keyboardType(.numberPad) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift new file mode 100644 index 00000000..e09354ed --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift @@ -0,0 +1,29 @@ +// +// 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 ServerUserPermissionsView { + + struct StatusSection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.status) { + + Toggle(L10n.active, isOn: Binding( + get: { !(policy.isDisabled ?? false) }, + set: { policy.isDisabled = !$0 } + )) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift new file mode 100644 index 00000000..e5b5283e --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift @@ -0,0 +1,29 @@ +// +// 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 ServerUserPermissionsView { + + struct SyncPlaySection: View { + + @Binding + var policy: UserPolicy + + var body: some View { + Section(L10n.syncPlay) { + + CaseIterablePicker( + L10n.permissions, + selection: $policy.syncPlayAccess.coalesce(.none) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift new file mode 100644 index 00000000..0d01b21a --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift @@ -0,0 +1,116 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct ServerUserPermissionsView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + var viewModel: ServerUserAdminViewModel + + // MARK: - Policy Variable + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + + guard let policy = viewModel.user.policy else { + preconditionFailure("User policy cannot be empty.") + } + + self.tempPolicy = policy + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.permissions) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + switch viewModel.state { + case let .error(error): + ErrorView(error: error) + case .initial: + ErrorView(error: JellyfinAPIError(L10n.loadingUserFailed)) + default: + permissionsListView + } + } + + // MARK: - Permissions List View + + @ViewBuilder + var permissionsListView: some View { + List { + StatusSection(policy: $tempPolicy) + + ManagementSection(policy: $tempPolicy) + + MediaPlaybackSection(policy: $tempPolicy) + + ExternalAccessSection(policy: $tempPolicy) + + SyncPlaySection(policy: $tempPolicy) + + RemoteControlSection(policy: $tempPolicy) + + PermissionSection(policy: $tempPolicy) + + SessionsSection(policy: $tempPolicy) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift new file mode 100644 index 00000000..3229403d --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift @@ -0,0 +1,151 @@ +// +// 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 ServerUsersView { + + struct ServerUsersRow: View { + + @Injected(\.currentUserSession) + private var userSession + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment Variables + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + @CurrentDate + private var currentDate: Date + + private let user: UserDto + + // MARK: - Actions + + private let onSelect: () -> Void + private let onDelete: () -> Void + + // MARK: - User Status Mapping + + private var userActive: Bool { + if let isDisabled = user.policy?.isDisabled { + return !isDisabled + } else { + return false + } + } + + // MARK: - Initializer + + init( + user: UserDto, + onSelect: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.onSelect = onSelect + self.onDelete = onDelete + } + + // MARK: - Label Styling + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return userActive ? .primary : .secondary } + + return isSelected ? .primary : .secondary + } + + // MARK: - User Image View + + @ViewBuilder + private var userImage: some View { + ZStack { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: userSession!.client, + maxWidth: 60 + ) + ) + .environment(\.isEnabled, userActive) + .isEditing(isEditing) + .isSelected(isSelected) + } + .frame(width: 60, height: 60) + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + + Text(user.name ?? L10n.unknown) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + + LabeledContent(L10n.role) { + if let isAdministrator = user.policy?.isAdministrator, + isAdministrator + { + Text(L10n.administrator) + } else { + Text(L10n.user) + } + } + + LabeledContent( + L10n.lastSeen, + value: user.lastActivityDate, + format: .lastSeen + ) + .id(currentDate) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(labelForegroundStyle, .secondary) + + Spacer() + + ListRowCheckbox() + } + } + + // MARK: - Body + + var body: some View { + ListRow { + userImage + } content: { + rowContent + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift new file mode 100644 index 00000000..d8cb28da --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift @@ -0,0 +1,259 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +struct ServerUsersView: View { + + @Default(.accentColor) + private var accentColor + + @Router + private var router + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedUsers: Set = [] + @State + private var isEditing: Bool = false + + @State + private var isHiddenFilterActive: Bool = false + @State + private var isDisabledFilterActive: Bool = false + + @StateObject + private var viewModel = ServerUsersViewModel() + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + userListView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.users) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(isEditing ? L10n.cancel : L10n.edit) { + isEditing.toggle() + + UIDevice.impact(.light) + + if !isEditing { + selectedUsers.removeAll() + } + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedUsers.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.gettingUsers), + isHidden: isEditing + ) { + Button(L10n.addUser, systemImage: "plus") { + router.route(to: .addServerUser()) + } + + if viewModel.users.isNotEmpty { + Button(L10n.editUsers, systemImage: "checkmark.circle") { + isEditing = true + } + } + + Divider() + + Section(L10n.filters) { + Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive) + Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive) + } + } + + .onChange(of: isDisabledFilterActive) { newValue in + viewModel.send(.getUsers( + isHidden: isHiddenFilterActive, + isDisabled: newValue + )) + } + .onChange(of: isHiddenFilterActive) { newValue in + viewModel.send(.getUsers( + isHidden: newValue, + isDisabled: isDisabledFilterActive + )) + } + .onFirstAppear { + viewModel.send(.getUsers()) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedUsersConfirmationActions + } message: { + Text(L10n.deleteSelectionUsersWarning) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteUserConfirmationActions + } message: { + Text(L10n.deleteUserWarning) + } + .alert(L10n.deleteUserFailed, isPresented: $isPresentingSelfDeleteError) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.deleteUserSelfDeletion(viewModel.userSession.user.username)) + } + .onNotification(.didAddServerUser) { newUser in + viewModel.send(.appendUser(newUser)) + router.route(to: .userDetails(user: newUser)) + } + } + + // MARK: - User List View + + @ViewBuilder + private var userListView: some View { + List { + InsetGroupedListHeader( + L10n.users, + description: L10n.allUsersDescription + ) { + UIApplication.shared.open(.jellyfinDocsUsers) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if viewModel.users.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } else { + ForEach(viewModel.users, id: \.self) { user in + if let userID = user.id { + ServerUsersRow(user: user) { + if isEditing { + selectedUsers.toggle(value: userID) + } else { + router.route(to: .userDetails(user: user)) + } + } onDelete: { + selectedUsers.removeAll() + selectedUsers.insert(userID) + isPresentingDeleteConfirmation = true + } + .isEditing(isEditing) + .isSelected(selectedUsers.contains(userID)) + .listRowInsets(.edgeInsets) + } + } + } + } + .listStyle(.plain) + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.getUsers(isHidden: isHiddenFilterActive, isDisabled: isDisabledFilterActive)) + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + + let isAllSelected: Bool = selectedUsers.count == viewModel.users.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedUsers = [] + } else { + selectedUsers = Set(viewModel.users.compactMap(\.id)) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - Delete Selected Users Confirmation Actions + + @ViewBuilder + private var deleteSelectedUsersConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.send(.deleteUsers(Array(selectedUsers))) + isEditing = false + selectedUsers.removeAll() + } + } + + // MARK: - Delete User Confirmation Actions + + @ViewBuilder + private var deleteUserConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let userToDelete = selectedUsers.first, selectedUsers.count == 1 { + if userToDelete == viewModel.userSession.user.id { + isPresentingSelfDeleteError = true + } else { + viewModel.send(.deleteUsers([userToDelete])) + selectedUsers.removeAll() + } + } + } + } +} diff --git a/Swiftfin/Views/AppIconSelectorView.swift b/Swiftfin/Views/AppIconSelectorView.swift new file mode 100644 index 00000000..e34bb6f0 --- /dev/null +++ b/Swiftfin/Views/AppIconSelectorView.swift @@ -0,0 +1,97 @@ +// +// 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 + +struct AppIconSelectorView: View { + + @ObservedObject + var viewModel: SettingsViewModel + + var body: some View { + Form { + + Section { + ForEach(PrimaryAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.dark) { + ForEach(DarkAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.light) { + ForEach(LightAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.invertedDark) { + ForEach(InvertedDarkAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.invertedLight) { + ForEach(InvertedLightAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + } + .navigationTitle(L10n.appIcon) + } +} + +extension AppIconSelectorView { + + struct AppIconRow: View { + + @Default(.accentColor) + private var accentColor + + @ObservedObject + var viewModel: SettingsViewModel + + let icon: any AppIcon + + var body: some View { + Button { + viewModel.select(icon: icon) + } label: { + HStack { + + Image(icon.iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .cornerRadius(12) + .shadow(radius: 2) + + Text(icon.displayTitle) + .foregroundColor(.primary) + + Spacer() + + if icon.iconName == viewModel.currentAppIcon.iconName { + Image(systemName: "checkmark.circle.fill") + .resizable() + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + } + } + } +} diff --git a/Swiftfin/Views/AppLoadingView.swift b/Swiftfin/Views/AppLoadingView.swift new file mode 100644 index 00000000..2d0f67f7 --- /dev/null +++ b/Swiftfin/Views/AppLoadingView.swift @@ -0,0 +1,37 @@ +// +// 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 + +/// The loading view for the app when migrations are taking place +struct AppLoadingView: View { + + @State + private var didFailMigration = false + + var body: some View { + ZStack { + if !didFailMigration { + DelayedProgressView() + } + + if didFailMigration { + ErrorView(error: JellyfinAPIError("An internal error occurred.")) + } + } + .topBarTrailing { + Button(L10n.advanced, systemImage: "gearshape.fill") {} + .foregroundStyle(.secondary) + .disabled(true) + .isVisible(!didFailMigration) + } + .onNotification(.didFailMigration) { _ in + didFailMigration = true + } + } +} diff --git a/Swiftfin/Views/AppSettingsView/AppSettingsView.swift b/Swiftfin/Views/AppSettingsView/AppSettingsView.swift new file mode 100644 index 00000000..09827ee0 --- /dev/null +++ b/Swiftfin/Views/AppSettingsView/AppSettingsView.swift @@ -0,0 +1,98 @@ +// +// 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 + +// TODO: move sign out-stuff into super user when implemented + +struct AppSettingsView: View { + + @Default(.accentColor) + private var accentColor + + @Default(.appAppearance) + private var appearance + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + + @Default(.signOutOnClose) + private var signOutOnClose + + @Router + private var router + + @StateObject + private var viewModel = SettingsViewModel() + + var body: some View { + Form { + + ChevronButton(L10n.about) { + router.route(to: .aboutApp) + } + + Section(L10n.accessibility) { + + ChevronButton(L10n.appIcon) { + // TODO: Create NavigationRoute.appIconSelector + router.route(to: .appIconSelector(viewModel: viewModel)) + } + + if !selectUserUseSplashscreen { + CaseIterablePicker( + L10n.appearance, + selection: $appearance + ) + } + } + + Section { + + Toggle(L10n.useSplashscreen, isOn: $selectUserUseSplashscreen) + + if selectUserUseSplashscreen { + Picker(L10n.servers, selection: $selectUserAllServersSplashscreen) { + + Section { + Label(L10n.random, systemImage: "dice.fill") + .tag(SelectUserServerSelection.all) + } + + ForEach(viewModel.servers) { server in + Text(server.name) + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } + } header: { + Text(L10n.splashscreen) + } footer: { + if selectUserUseSplashscreen { + Text(L10n.splashscreenFooter) + } + } + + SignOutIntervalSection() + + ChevronButton(L10n.logs) { + router.route(to: .log) + } + } + .animation(.linear, value: selectUserUseSplashscreen) + .navigationTitle(L10n.advanced) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift b/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift new file mode 100644 index 00000000..cdc1542f --- /dev/null +++ b/Swiftfin/Views/AppSettingsView/Components/SignOutIntervalSection.swift @@ -0,0 +1,71 @@ +// +// 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 AppSettingsView { + + struct SignOutIntervalSection: View { + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + @Default(.signOutOnBackground) + private var signOutOnBackground + @Default(.signOutOnClose) + private var signOutOnClose + + @State + private var isEditingBackgroundSignOutInterval: Bool = false + + var body: some View { + Section { + Toggle(L10n.signoutClose, isOn: $signOutOnClose) + } footer: { + Text(L10n.signoutCloseFooter) + } + + Section { + Toggle(L10n.signoutBackground, isOn: $signOutOnBackground) + + if signOutOnBackground { + HStack { + Text(L10n.duration) + + Spacer() + + Button { + isEditingBackgroundSignOutInterval.toggle() + } label: { + HStack { + Text(backgroundSignOutInterval, format: .hourMinute) + .foregroundStyle(.secondary) + + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(isEditingBackgroundSignOutInterval ? .degrees(90) : .zero) + .animation(.linear(duration: 0.075), value: isEditingBackgroundSignOutInterval) + } + } + .foregroundStyle(.primary, .secondary) + } + + if isEditingBackgroundSignOutInterval { + HourMinutePicker(interval: $backgroundSignOutInterval) + } + } + } footer: { + Text( + L10n.signoutBackgroundFooter + ) + } + .animation(.linear(duration: 0.15), value: isEditingBackgroundSignOutInterval) + } + } +} diff --git a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift new file mode 100644 index 00000000..212656d6 --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -0,0 +1,188 @@ +// +// 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 CollectionVGrid +import Defaults +import Foundation +import JellyfinAPI +import SwiftUI + +// TODO: remove and flatten to `PagingLibraryView` + +// TODO: sorting by number/filtering +// - see if can use normal filter view model? +// - how to add custom filters for data context? +// TODO: saving item display type/detailed column count +// - wait until after user refactor + +// Note: Repurposes `LibraryDisplayType` to save from creating a new type. +// If there are other places where detailed/compact contextually differ +// from the library types, then create a new type and use it here. +// - list: detailed +// - grid: compact + +struct ChannelLibraryView: View { + + @Router + private var router + + @State + private var channelDisplayType: LibraryDisplayType = .list + @State + private var layout: CollectionVGridLayout + + @StateObject + private var viewModel = ChannelLibraryViewModel() + + // MARK: init + + init() { + if UIDevice.isPhone { + layout = Self.padlayout(channelDisplayType: .list) + } else { + layout = Self.phonelayout(channelDisplayType: .list) + } + } + + // MARK: layout + + private static func padlayout( + channelDisplayType: LibraryDisplayType + ) -> CollectionVGridLayout { + switch channelDisplayType { + case .grid: + .minWidth(150) + case .list: + .minWidth(250) + } + } + + private static func phonelayout( + channelDisplayType: LibraryDisplayType + ) -> CollectionVGridLayout { + switch channelDisplayType { + case .grid: + .columns(3) + case .list: + .columns(1) + } + } + + // MARK: item view + + private func compactChannelView(channel: ChannelProgram) -> some View { + CompactChannelView(channel: channel.channel) { + router.route( + to: .videoPlayer( + provider: channel.channel.getPlaybackItemProvider( + userSession: viewModel.userSession + ) + ) + ) + } + } + + private func detailedChannelView(channel: ChannelProgram) -> some View { + DetailedChannelView(channel: channel) { + router.route( + to: .videoPlayer( + provider: channel.channel.getPlaybackItemProvider( + userSession: viewModel.userSession + ) + ) + ) + } + } + + @ViewBuilder + private var contentView: some View { + CollectionVGrid( + uniqueElements: viewModel.elements, + layout: layout + ) { channel in + switch channelDisplayType { + case .grid: + compactChannelView(channel: channel) + case .list: + detailedChannelView(channel: channel) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .navigationTitle(L10n.channels) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: channelDisplayType) { newValue in + if UIDevice.isPhone { + layout = Self.phonelayout(channelDisplayType: newValue) + } else { + layout = Self.padlayout(channelDisplayType: newValue) + } + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .sinceLastDisappear { interval in + // refresh after 3 hours + if interval >= 10800 { + viewModel.send(.refresh) + } + } + .topBarTrailing { + + if viewModel.backgroundStates.contains(.gettingNextPage) { + ProgressView() + } + + Menu { + // We repurposed `LibraryDisplayType` but want different labels + Picker(L10n.channelDisplay, selection: $channelDisplayType) { + + Label(L10n.compact, systemImage: LibraryDisplayType.grid.systemImage) + .tag(LibraryDisplayType.grid) + + Label(L10n.detailed, systemImage: LibraryDisplayType.list.systemImage) + .tag(LibraryDisplayType.list) + } + } label: { + Label( + channelDisplayType.displayTitle, + systemImage: channelDisplayType.systemImage + ) + } + } + } +} diff --git a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift new file mode 100644 index 00000000..ef726384 --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift @@ -0,0 +1,37 @@ +// +// 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 ChannelLibraryView { + + struct CompactChannelView: View { + + let channel: BaseItemDto + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading) { + PosterImage( + item: channel, + type: .square + ) + + Text(channel.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + .font(.footnote.weight(.regular)) + } + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift new file mode 100644 index 00000000..20e97d3d --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift @@ -0,0 +1,134 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: can look busy with 3 programs, probably just do 2? + +extension ChannelLibraryView { + + struct DetailedChannelView: View { + + @Default(.accentColor) + private var accentColor + + @State + private var contentSize: CGSize = .zero + @State + private var now: Date = .now + + let channel: ChannelProgram + let action: () -> Void + + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + @ViewBuilder + private var channelLogo: some View { + VStack { + PosterImage( + item: channel.channel, + type: .square + ) + + Text(channel.channel.number ?? "") + .font(.body) + .lineLimit(1) + .foregroundColor(Color.jellyfinPurple) + } + } + + @ViewBuilder + private func programLabel(for program: BaseItemDto) -> some View { + HStack(alignment: .top) { + AlternateLayoutView(alignment: .leading) { + Text("00:00 AAA") + .monospacedDigit() + } content: { + if let startDate = program.startDate { + Text(startDate, style: .time) + .monospacedDigit() + } else { + Text(String.emptyRuntime) + } + } + + Text(program.displayTitle) + } + .lineLimit(1) + } + + @ViewBuilder + private var programListView: some View { + VStack(alignment: .leading, spacing: 0) { + if let currentProgram = channel.currentProgram { + ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) + .frame(height: 5) + .padding(.bottom, 5) + .foregroundStyle(accentColor) + + programLabel(for: currentProgram) + .font(.footnote.weight(.bold)) + } + + if let nextProgram = channel.programAfterCurrent(offset: 0) { + programLabel(for: nextProgram) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let futureProgram = channel.programAfterCurrent(offset: 1) { + programLabel(for: futureProgram) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .id(channel.currentProgram) + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Button(action: action) { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { + + channelLogo + .frame(width: 80) + .padding(.vertical, 8) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(channel.displayTitle) + .font(.body) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundStyle(.primary) + + if channel.programs.isNotEmpty { + programListView + } + } + + Spacer() + } + .frame(maxWidth: .infinity) + .trackingSize($contentSize) + } + } + .buttonStyle(.plain) + + Color.secondarySystemFill + .frame(width: contentSize.width, height: 1) + } + .onReceive(timer) { newValue in + now = newValue + } + .animation(.linear(duration: 0.2), value: channel.currentProgram) + } + } +} diff --git a/Swiftfin/Views/DownloadListView.swift b/Swiftfin/Views/DownloadListView.swift new file mode 100644 index 00000000..318ff350 --- /dev/null +++ b/Swiftfin/Views/DownloadListView.swift @@ -0,0 +1,64 @@ +// +// 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 DownloadListView: View { + + @ObservedObject + var viewModel: DownloadListViewModel + + var body: some View { + ScrollView(showsIndicators: false) { + ForEach(viewModel.items) { item in + DownloadTaskRow(downloadTask: item) + } + } + .navigationTitle(L10n.downloads) + .navigationBarTitleDisplayMode(.inline) + } +} + +extension DownloadListView { + + struct DownloadTaskRow: View { + + @Router + private var router + + let downloadTask: DownloadTask + + var body: some View { + Button { + router.route(to: .downloadTask(downloadTask: downloadTask)) + } label: { + HStack(alignment: .bottom) { + ImageView(downloadTask.getImageURL(name: "Primary")) + .failure { + Color.secondary + .opacity(0.8) + } +// .posterStyle(type: .portrait, width: 60) + .posterShadow() + + VStack(alignment: .leading) { + Text(downloadTask.item.displayTitle) + .foregroundColor(.primary) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical) + + Spacer() + } + } + } + } +} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift new file mode 100644 index 00000000..40f80f01 --- /dev/null +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift @@ -0,0 +1,174 @@ +// +// 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 DownloadTaskView { + + struct ContentView: View { + + @Default(.accentColor) + private var accentColor + + @Injected(\.downloadManager) + private var downloadManager + + @Router + private var router + + @ObservedObject + var downloadTask: DownloadTask + + @State + private var isPresentingVideoPlayerTypeError: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .center) { + ImageView(downloadTask.item.landscapeImageSources(maxWidth: 600)) + .frame(maxHeight: 300) + .aspectRatio(1.77, contentMode: .fill) + .cornerRadius(10) + .padding(.horizontal) + .posterShadow() + + ShelfView(downloadTask: downloadTask) + + // TODO: Break into subview + switch downloadTask.state { + case .ready, .cancelled: + PrimaryButton(title: "Download") + .onSelect { + downloadManager.download(task: downloadTask) + } + .frame(maxWidth: 300) + .frame(height: 50) + case let .downloading(progress): + HStack { +// CircularProgressView(progress: progress) +// .buttonStyle(.plain) +// .frame(width: 30, height: 30) + + Text("\(Int(progress * 100))%") + .foregroundColor(.secondary) + + Spacer() + + Button { + downloadManager.cancel(task: downloadTask) + } label: { + Image(systemName: "stop.circle") + .foregroundColor(.red) + } + } + .padding(.horizontal) + case let .error(error): + VStack { + PrimaryButton(title: L10n.retry) + .onSelect { + downloadManager.download(task: downloadTask) + } + .frame(maxWidth: 300) + .frame(height: 50) + + Text("Error: \(error.localizedDescription)") + .padding(.horizontal) + } + case .complete: + PrimaryButton(title: L10n.play) + .onSelect { + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + router.dismiss() +// router.route(to: .videoPlayer(manager: DownloadVideoPlayerManager(downloadTask: downloadTask))) + } else { + isPresentingVideoPlayerTypeError = true + } + } + .frame(maxWidth: 300) + .frame(height: 50) + } + } + +// Text("Media Info") +// .font(.title2) +// .fontWeight(.semibold) +// .padding(.horizontal) + } + .alert( + L10n.error, + isPresented: $isPresentingVideoPlayerTypeError + ) { + Button { + isPresentingVideoPlayerTypeError = false + } label: { + Text(L10n.dismiss) + } + } message: { + Text("Downloaded items are only playable through the Swiftfin video player.") + } + } + } +} + +extension DownloadTaskView.ContentView { + + struct ShelfView: View { + + @ObservedObject + var downloadTask: DownloadTask + + var body: some View { + VStack(alignment: .center, spacing: 10) { + + if let seriesName = downloadTask.item.seriesName { + Text(seriesName) + .font(.headline) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + .foregroundColor(.secondary) + } + + Text(downloadTask.item.displayTitle) + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + + DotHStack { + if downloadTask.item.type == .episode { + if let episodeLocation = downloadTask.item.episodeLocator { + Text(episodeLocation) + } + } else { + if let firstGenre = downloadTask.item.genres?.first { + Text(firstGenre) + } + } + + if let productionYear = downloadTask.item.premiereDateYear { + Text(productionYear) + } + + if let runtime = downloadTask.item.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + } + } +} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift new file mode 100644 index 00000000..b8bdd413 --- /dev/null +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift @@ -0,0 +1,28 @@ +// +// 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 + +struct DownloadTaskView: View { + + @Router + private var router + + @ObservedObject + var downloadTask: DownloadTask + + var body: some View { + ScrollView(showsIndicators: false) { + ContentView(downloadTask: downloadTask) + } + .navigationBarCloseButton { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/EditServerView.swift b/Swiftfin/Views/EditServerView.swift new file mode 100644 index 00000000..416d8f1e --- /dev/null +++ b/Swiftfin/Views/EditServerView.swift @@ -0,0 +1,92 @@ +// +// 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 + +// TODO: change URL picker from menu to list with network-url mapping + +/// - Note: Set the environment `isEditing` to `true` to +/// allow server deletion +struct EditServerView: View { + + @Router + private var router + + @Environment(\.isEditing) + private var isEditing + + @State + private var currentServerURL: URL + @State + private var isPresentingConfirmDeletion: Bool = false + + @StateObject + private var viewModel: ServerConnectionViewModel + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) + self._currentServerURL = State(initialValue: server.currentURL) + } + + var body: some View { + List { + Section { + + LabeledContent( + L10n.name, + value: viewModel.server.name + ) + + if let serverVerion = StoredValues[.Server.publicInfo(id: viewModel.server.id)].version { + LabeledContent( + L10n.version, + value: serverVerion + ) + } + + Picker(L10n.url, selection: $currentServerURL) { + ForEach(viewModel.server.urls.sorted(using: \.absoluteString), id: \.self) { url in + Text(url.absoluteString) + .tag(url) + .foregroundColor(.secondary) + } + } + } footer: { + if !viewModel.server.isVersionCompatible { + Label( + L10n.serverVersionWarning(JellyfinClient.sdkVersion.majorMinor.description), + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + if isEditing { + ListRowButton(L10n.delete) { + isPresentingConfirmDeletion = true + } + .foregroundStyle(.red, .red.opacity(0.2)) + } + } + .navigationTitle(L10n.server) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: currentServerURL) { newValue in + viewModel.setCurrentURL(to: newValue) + } + .alert(L10n.deleteServer, isPresented: $isPresentingConfirmDeletion) { + Button(L10n.delete, role: .destructive) { + viewModel.delete() + router.dismiss() + } + } message: { + Text(L10n.confirmDeleteServerAndUsers(viewModel.server.name)) + } + } +} diff --git a/Swiftfin/Views/FilterView.swift b/Swiftfin/Views/FilterView.swift new file mode 100644 index 00000000..e2124d56 --- /dev/null +++ b/Swiftfin/Views/FilterView.swift @@ -0,0 +1,95 @@ +// +// 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 + +// TODO: multiple filter types? +// - for sort order and sort by combined +struct FilterView: View { + + // MARK: - Binded Variable + + @Binding + private var selection: [AnyItemFilter] + + // MARK: - Environment & Observed Objects + + @Router + private var router + + @ObservedObject + private var viewModel: FilterViewModel + + // MARK: - Filter Type + + private let type: ItemFilterType + + // MARK: - Filter Sources + + private var filterSource: [AnyItemFilter] { + viewModel.allFilters[keyPath: type.collectionAnyKeyPath] + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + Button(L10n.reset) { + viewModel.send(.reset(type)) + } + .environment( + \.isEnabled, + viewModel.isFilterSelected(type: type) + ) + } + } + + // MARK: - Filter Content + + @ViewBuilder + private var contentView: some View { + if filterSource.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + SelectorView( + selection: $selection, + sources: filterSource, + type: type.selectorType + ) + } + } +} + +extension FilterView { + + init( + viewModel: FilterViewModel, + type: ItemFilterType + ) { + + let selectionBinding: Binding<[AnyItemFilter]> = Binding { + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] + } set: { newValue in + viewModel.send(.update(type, newValue)) + } + + self.init( + selection: selectionBinding, + viewModel: viewModel, + type: type + ) + } +} diff --git a/Swiftfin/Views/FontPickerView.swift b/Swiftfin/Views/FontPickerView.swift new file mode 100644 index 00000000..43de9383 --- /dev/null +++ b/Swiftfin/Views/FontPickerView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import UIKit + +struct FontPickerView: View { + + let selection: Binding + + private var elements: [DisplayableBox] { + UIFont.familyNames + .map(DisplayableBox.init) + } + + var body: some View { + SelectorView( + selection: selection.map( + getter: DisplayableBox.init, + setter: { $0.displayTitle } + ), + sources: elements + ) + .label { fontFamily in + Text(fontFamily.displayTitle) + .foregroundColor(.primary) + .font(.custom(fontFamily.displayTitle, size: 18)) + } + .navigationTitle(L10n.subtitleFont) + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift new file mode 100644 index 00000000..16158bb0 --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -0,0 +1,75 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import SwiftUI + +extension HomeView { + + struct ContinueWatchingView: View { + + @Router + private var router + + @ObservedObject + var viewModel: HomeViewModel + + // TODO: see how this looks across multiple screen sizes + // alongside PosterHStack + landscape + // TODO: need better handling for iPadOS + portrait orientation + private var columnCount: CGFloat { + if UIDevice.isPhone { + 1.5 + } else { + 3.5 + } + } + + var body: some View { + CollectionHStack( + uniqueElements: viewModel.resumeItems, + columns: columnCount + ) { item in + PosterButton( + item: item, + type: .landscape + ) { namespace in + router.route(to: .item(item: item), in: namespace) + } label: { + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent(item: item) + } else { + PosterButton.TitleSubtitleContentView(item: item) + } + } + } + .clipsToBounds(false) + .scrollBehavior(.continuousLeadingEdge) + .contextMenu(for: BaseItemDto.self) { item in + Button { + viewModel.send(.setIsPlayed(true, item)) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") + } + + Button(role: .destructive) { + viewModel.send(.setIsPlayed(false, item)) + } label: { + Label(L10n.unplayed, systemImage: "minus.circle") + } + } + .posterOverlay(for: BaseItemDto.self) { item in + LandscapePosterProgressBar( + title: item.progressLabel ?? L10n.continue, + progress: (item.userData?.playedPercentage ?? 0) / 100 + ) + } + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift new file mode 100644 index 00000000..d21b31d8 --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift @@ -0,0 +1,43 @@ +// +// 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 HomeView { + + struct LatestInLibraryView: View { + + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + + @Router + private var router + + @ObservedObject + var viewModel: LatestInLibraryViewModel + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + type: latestInLibraryPosterType, + items: viewModel.elements + ) { item, namespace in + router.route(to: .item(item: item), in: namespace) + } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: .library(viewModel: viewModel)) + } + } + } + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift new file mode 100644 index 00000000..98052fbf --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/NextUpView.swift @@ -0,0 +1,73 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension HomeView { + + struct NextUpView: View { + + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + + @Router + private var router + + @ObservedObject + var viewModel: NextUpLibraryViewModel + + private var onSetPlayed: (BaseItemDto) -> Void + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.nextUp, + type: nextUpPosterType, + items: viewModel.elements + ) { item, namespace in + router.route(to: .item(item: item), in: namespace) + } label: { item in + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent(item: item) + } else { + PosterButton.TitleSubtitleContentView(item: item) + } + } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: .library(viewModel: viewModel)) + } + } + .contextMenu(for: BaseItemDto.self) { item in + Button { + onSetPlayed(item) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") + } + } + } + } + } +} + +extension HomeView.NextUpView { + + init(viewModel: NextUpLibraryViewModel) { + self.init( + viewModel: viewModel, + onSetPlayed: { _ in } + ) + } + + func onSetPlayed(perform action: @escaping (BaseItemDto) -> Void) -> Self { + copy(modifying: \.onSetPlayed, with: action) + } +} diff --git a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift new file mode 100644 index 00000000..386d9fbe --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift @@ -0,0 +1,47 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension HomeView { + + struct RecentlyAddedView: View { + + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + + @Router + private var router + + @ObservedObject + var viewModel: RecentlyAddedLibraryViewModel + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + title: L10n.recentlyAdded, + type: recentlyAddedPosterType, + items: viewModel.elements + ) { item, namespace in + router.route(to: .item(item: item), in: namespace) + } + .trailing { + SeeAllButton() + .onSelect { + // Give a new view model becaues we don't want to + // keep paginated items on the home view model + let viewModel = RecentlyAddedLibraryViewModel() + router.route(to: .library(viewModel: viewModel)) + } + } + } + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift new file mode 100644 index 00000000..da3f8073 --- /dev/null +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -0,0 +1,102 @@ +// +// 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 Foundation +import SwiftUI + +// TODO: seems to redraw view when popped to sometimes? +// - similar to MediaView TODO bug? +// - indicated by snapping to the top +struct HomeView: View { + + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.Home.showRecentlyAdded) + private var showRecentlyAdded + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + + @Router + private var router + + @StateObject + private var viewModel = HomeViewModel() + + @ViewBuilder + private var contentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + + ContinueWatchingView(viewModel: viewModel) + + NextUpView(viewModel: viewModel.nextUpViewModel) + .onSetPlayed { item in + viewModel.send(.setIsPlayed(true, item)) + } + + if showRecentlyAdded { + RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) + } + + ForEach(viewModel.libraries) { viewModel in + LatestInLibraryView(viewModel: viewModel) + } + } + .edgePadding(.vertical) + } + .refreshable { + viewModel.send(.refresh) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .onFirstAppear { + viewModel.send(.refresh) + } + .navigationTitle(L10n.home) + .topBarTrailing { + + if viewModel.backgroundStates.contains(.refresh) { + ProgressView() + } + + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + router.route(to: .settings) + } + } + .sinceLastDisappear { interval in + if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { + viewModel.send(.backgroundRefresh) + viewModel.notificationsReceived.remove(.itemMetadataDidChange) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift new file mode 100644 index 00000000..591851e9 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift @@ -0,0 +1,98 @@ +// +// 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 ItemEditorView { + + struct RefreshMetadataButton: View { + + // MARK: - Environment & State Objects + + // Bug in SwiftUI where Menu item icons will be black in dark mode + // when a HierarchicalShapeStyle is applied to the Buttons + @Environment(\.colorScheme) + private var colorScheme: ColorScheme + + @StateObject + private var viewModel: RefreshMetadataViewModel + + // MARK: - Initializer + + init(item: BaseItemDto) { + _viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item)) + } + + // MARK: - Body + + var body: some View { + Menu { + Group { + Button(L10n.findMissing, systemImage: "magnifyingglass") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: false + ) + } + + Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .none, + replaceMetadata: true, + replaceImages: false + ) + } + + Button(L10n.replaceImages, systemImage: "photo") { + viewModel.refreshMetadata( + metadataRefreshMode: .none, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: true + ) + } + + Button(L10n.replaceAll, systemImage: "staroflife") { + viewModel.refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: true, + replaceImages: true + ) + } + } + .foregroundStyle(colorScheme == .dark ? Color.white : Color.black) + } label: { + HStack { + Text(L10n.refreshMetadata) + .foregroundStyle(.primary) + + Spacer() + + if viewModel.state == .refreshing { + ProgressView(value: viewModel.progress) + .progressViewStyle(.gauge) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .frame(width: 25, height: 25) + } else { + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + .fontWeight(.semibold) + } + } + } + .foregroundStyle(.primary, .secondary) + .disabled(viewModel.state == .refreshing) + .errorMessage($viewModel.error) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift new file mode 100644 index 00000000..03223c91 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift @@ -0,0 +1,56 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension IdentifyItemView { + + struct RemoteSearchResultRow: View { + + // MARK: - Remote Search Result Variable + + let result: RemoteSearchResult + + // MARK: - Remote Search Result Action + + let onSelect: () -> Void + + // MARK: - Result Title + + private var resultTitle: String { + result.displayTitle + .appending(" (\(result.premiereDate!.formatted(.dateTime.year())))", if: result.premiereDate != nil) + } + + // MARK: - Body + + var body: some View { + ListRow { + IdentifyItemView.resultImage(URL(string: result.imageURL)) + .frame(width: 60) + } content: { + VStack(alignment: .leading) { + Text(resultTitle) + .font(.headline) + .foregroundStyle(.primary) + + if let overview = result.overview { + Text(overview) + .lineLimit(3) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift new file mode 100644 index 00000000..10755072 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift @@ -0,0 +1,130 @@ +// +// 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 IdentifyItemView { + + struct RemoteSearchResultView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - Item Info Variables + + @StateObject + var viewModel: IdentifyItemViewModel + + let result: RemoteSearchResult + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + @ViewBuilder + private var header: some View { + Section { + HStack(alignment: .bottom, spacing: 12) { + IdentifyItemView.resultImage(URL(string: result.imageURL)) + .frame(width: 100) + .accessibilityIgnoresInvertColors() + + Text(result.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .padding(.bottom) + } + } + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + + @ViewBuilder + private var resultDetails: some View { + Section(L10n.details) { + + if let premiereDate = result.premiereDate { + LabeledContent( + L10n.premiereDate, + value: premiereDate, + format: .dateTime.year().month().day() + ) + } + + if let productionYear = result.productionYear { + LabeledContent( + L10n.productionYear, + value: productionYear, + format: .number.grouping(.never) + ) + } + + if let provider = result.searchProviderName { + LabeledContent( + L10n.provider, + value: provider + ) + } + + if let providerID = result.providerIDs?.values.first { + LabeledContent( + L10n.id, + value: providerID + ) + } + } + + if let overview = result.overview { + Section(L10n.overview) { + Text(overview) + } + } + } + + var body: some View { + List { + header + + resultDetails + } + .navigationTitle(L10n.identify) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + Button(L10n.save) { + viewModel.send(.update(result)) + } + .buttonStyle(.toolbarPill) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .cancelled: + break + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift new file mode 100644 index 00000000..8e7c8b6f --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift @@ -0,0 +1,188 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct IdentifyItemView: View { + + private struct SearchFields: Equatable { + var name: String? + var originalTitle: String? + var year: Int? + + var isEmpty: Bool { + name.isNilOrEmpty && + originalTitle.isNilOrEmpty && + year == nil + } + } + + @Default(.accentColor) + private var accentColor + + @FocusState + private var isTitleFocused: Bool + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: IdentifyItemViewModel + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Lookup States + + @State + private var search = SearchFields() + + // MARK: - Initializer + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: IdentifyItemViewModel(item: item)) + } + + // MARK: - Body + + var body: some View { + Group { + switch viewModel.state { + case .content, .searching: + contentView + case .updating: + ProgressView() + } + } + .navigationTitle(L10n.identify) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.state == .updating) + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + case .cancelled: + break + case .updated: + router.dismiss() + } + } + .errorMessage($error) + .onFirstAppear { + isTitleFocused = true + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + Form { + ListTitleSection( + viewModel.item.name ?? L10n.unknown, + description: viewModel.item.path + ) + + searchView + + resultsView + } + } + + // MARK: - Search View + + @ViewBuilder + private var searchView: some View { + Section(L10n.search) { + TextField( + L10n.title, + text: $search.name.coalesce("") + ) + .focused($isTitleFocused) + + TextField( + L10n.originalTitle, + text: $search.originalTitle.coalesce("") + ) + + TextField( + L10n.year, + text: $search.year + .map( + getter: { $0 == nil ? "" : "\($0!)" }, + setter: { Int($0) } + ) + ) + .keyboardType(.numberPad) + } + + if viewModel.state == .searching { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.search) { + viewModel.send(.search( + name: search.name, + originalTitle: search.originalTitle, + year: search.year + )) + } + .disabled(search.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + } + } + + // MARK: - Results View + + @ViewBuilder + private var resultsView: some View { + if viewModel.searchResults.isNotEmpty { + Section(L10n.items) { + ForEach(viewModel.searchResults) { result in + RemoteSearchResultRow(result: result) { + router.route( + to: .identifyItemResults( + viewModel: viewModel, + result: result + ) + ) + } + } + } + } + } + + // MARK: - Result Image + + @ViewBuilder + static func resultImage(_ url: URL?) -> some View { + ZStack { + Color.clear + + ImageView(url) + .failure { + Image(systemName: "questionmark") + .foregroundStyle(.primary) + } + } + .posterStyle(.portrait) + .posterShadow() + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift new file mode 100644 index 00000000..2afda5d7 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -0,0 +1,186 @@ +// +// 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 + +struct ItemEditorView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - ViewModel + + @ObservedObject + var viewModel: ItemViewModel + + // MARK: - Can Edit Metadata + + private var canEditMetadata: Bool { + viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) == true + } + + // MARK: - Can Manage Subtitles + + private var canManageSubtitles: Bool { + viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) == true + } + + // MARK: - Can Manage Lyrics + + private var canManageLyrics: Bool { + viewModel.userSession.user.permissions.items.canManageLyrics(item: viewModel.item) == true + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content, .refreshing: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationTitle(L10n.metadata) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + } + + // MARK: - Content View + + private var contentView: some View { + List { + ListTitleSection( + viewModel.item.name ?? L10n.unknown, + description: viewModel.item.path + ) + + /// Hide metadata options to Lyric/Subtitle only users + if canEditMetadata { + + refreshButtonView + + Section(L10n.edit) { + editMetadataView + editTextView + } + + if viewModel.item.hasComponents { + editComponentsView + } + } /* else if canManageSubtitles || canManageLyrics { + + // TODO: Enable when Subtitle / Lyric Editing is added + Section(L10n.edit) { + editTextView + } + }*/ + } + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + // MARK: - Refresh Menu Button + + @ViewBuilder + private var refreshButtonView: some View { + Section { + RefreshMetadataButton(item: viewModel.item) + } footer: { + LearnMoreButton(L10n.metadata) { + LabeledContent( + L10n.findMissing, + value: L10n.findMissingDescription + ) + LabeledContent( + L10n.replaceMetadata, + value: L10n.replaceMetadataDescription + ) + LabeledContent( + L10n.replaceImages, + value: L10n.replaceImagesDescription + ) + LabeledContent( + L10n.replaceAll, + value: L10n.replaceAllDescription + ) + } + } + } + + // MARK: - Editable Metadata Routing Buttons + + @ViewBuilder + private var editMetadataView: some View { + + if let itemKind = viewModel.item.type, + BaseItemKind.itemIdentifiableCases.contains(itemKind) + { + ChevronButton(L10n.identify) { + router.route(to: .identifyItem(item: viewModel.item)) + } + } + ChevronButton(L10n.images) { + router.route(to: .itemImages(viewModel: ItemImagesViewModel(item: viewModel.item))) + } + ChevronButton(L10n.metadata) { + router.route(to: .editMetadata(item: viewModel.item)) + } + } + + // MARK: - Editable Text Routing Buttons + + @ViewBuilder + private var editTextView: some View { + if canManageSubtitles { + ChevronButton(L10n.subtitles) { + router.route(to: .editSubtitles(item: viewModel.item)) + } + } + if canManageLyrics { +// ChevronButton(L10n.lyrics) { +// router.route(to: \.editLyrics, viewModel.item) +// } + } + } + + // MARK: - Editable Metadata Components Routing Buttons + + @ViewBuilder + private var editComponentsView: some View { + Section { + ChevronButton(L10n.genres) { + router.route(to: .editGenres(item: viewModel.item)) + } + ChevronButton(L10n.people) { + router.route(to: .editPeople(item: viewModel.item)) + } + ChevronButton(L10n.tags) { + router.route(to: .editTags(item: viewModel.item)) + } + ChevronButton(L10n.studios) { + router.route(to: .editStudios(item: viewModel.item)) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift new file mode 100644 index 00000000..134fa8d8 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift @@ -0,0 +1,196 @@ +// +// 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 BlurHashKit +import CollectionVGrid +import JellyfinAPI +import SwiftUI + +// TODO: different layouts per image type +// - also based on iOS vs iPadOS + +struct AddItemImageView: View { + + // MARK: - Observed, & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ItemImagesViewModel + + @StateObject + private var remoteImageInfoViewModel: RemoteImageInfoViewModel + + // MARK: - Dialog State + + @State + private var error: Error? + + // MARK: - Collection Layout + + @State + private var layout: CollectionVGridLayout = .minWidth(150) + + // MARK: - Initializer + + init(viewModel: ItemImagesViewModel, imageType: ImageType) { + self.viewModel = viewModel + self._remoteImageInfoViewModel = StateObject( + wrappedValue: RemoteImageInfoViewModel( + imageType: imageType, + parent: viewModel.item + ) + ) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch remoteImageInfoViewModel.state { + case .initial, .refreshing: + DelayedProgressView() + case .content: + gridView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + } + .animation(.linear(duration: 0.1), value: remoteImageInfoViewModel.state) + .navigationTitle(remoteImageInfoViewModel.imageType.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) + .navigationBarMenuButton(isLoading: viewModel.backgroundStates.contains(.updating)) { + Button { + remoteImageInfoViewModel.includeAllLanguages.toggle() + } label: { + if remoteImageInfoViewModel.includeAllLanguages { + Label(L10n.allLanguages, systemImage: "checkmark") + } else { + Text(L10n.allLanguages) + } + } + + if remoteImageInfoViewModel.providers.isNotEmpty { + Menu { + Button { + remoteImageInfoViewModel.provider = nil + } label: { + if remoteImageInfoViewModel.provider == nil { + Label(L10n.all, systemImage: "checkmark") + } else { + Text(L10n.all) + } + } + + ForEach(remoteImageInfoViewModel.providers, id: \.self) { provider in + Button { + remoteImageInfoViewModel.provider = provider + } label: { + if remoteImageInfoViewModel.provider == provider { + Label(provider, systemImage: "checkmark") + } else { + Text(provider) + } + } + } + } label: { + Text(L10n.provider) + + Text(remoteImageInfoViewModel.provider ?? L10n.all) + } + } + } + .onFirstAppear { + remoteImageInfoViewModel.send(.refresh) + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: + UIDevice.feedback(.success) + router.dismiss() + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content Grid View + + @ViewBuilder + private var gridView: some View { + if remoteImageInfoViewModel.elements.isEmpty { + Text(L10n.none) + } else { + CollectionVGrid( + uniqueElements: remoteImageInfoViewModel.elements, + layout: layout + ) { image in + imageButton(image) + } + .onReachedBottomEdge(offset: .offset(300)) { + remoteImageInfoViewModel.send(.getNextPage) + } + } + } + + // MARK: - Poster Image Button + + @ViewBuilder + private func imageButton(_ image: RemoteImageInfo) -> some View { + Button { + router.route( + to: .itemSearchImageDetails( + viewModel: viewModel, + remoteImageInfo: image + ) + ) + } label: { + posterImage( + image, + posterStyle: (image.height ?? 0) > (image.width ?? 0) ? .portrait : .landscape + ) + } + } + + // MARK: - Poster Image + + @ViewBuilder + private func posterImage( + _ posterImageInfo: RemoteImageInfo?, + posterStyle: PosterDisplayType + ) -> some View { + ZStack { + Color.secondarySystemFill + .frame(maxWidth: .infinity, maxHeight: .infinity) + + ImageView(posterImageInfo?.url?.url) + .placeholder { source in + if let blurHash = source.blurHash { + BlurHashView(blurHash: blurHash) + .scaledToFit() + } else { + Image(systemName: "photo") + } + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + .foregroundStyle(.secondary) + .font(.headline) + } + .posterStyle(posterStyle) + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift new file mode 100644 index 00000000..f762d4e2 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift @@ -0,0 +1,50 @@ +// +// 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 ItemImageDetailsView { + + struct DeleteButton: View { + + // MARK: - Delete Action + + let onDelete: () -> Void + + // MARK: - Dialog State + + @State + private var isPresentingConfirmation: Bool = false + + // MARK: - Body + + var body: some View { + ListRowButton(L10n.delete, role: .destructive) { + isPresentingConfirmation = true + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingConfirmation, + titleVisibility: .visible + ) { + Button( + L10n.delete, + role: .destructive, + action: onDelete + ) + + Button(L10n.cancel, role: .cancel) { + isPresentingConfirmation = false + } + } message: { + Text(L10n.deleteItemConfirmationMessage) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift new file mode 100644 index 00000000..fefc2cf9 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift @@ -0,0 +1,102 @@ +// +// 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 ItemImageDetailsView { + + struct DetailsSection: View { + + // MARK: - Image Details Variables + + private let index: Int? + private let language: String? + private let width: Int? + private let height: Int? + private let provider: String? + + // MARK: - Image Ratings Variables + + private let rating: Double? + private let ratingVotes: Int? + + // MARK: - Image Source Variable + + private let url: URL? + + // MARK: - Initializer + + init( + url: URL? = nil, + index: Int? = nil, + language: String? = nil, + width: Int? = nil, + height: Int? = nil, + provider: String? = nil, + rating: Double? = nil, + ratingType: RatingType? = nil, + ratingVotes: Int? = nil + ) { + self.url = url + self.index = index + self.language = language + self.width = width + self.height = height + self.provider = provider + self.rating = rating + self.ratingVotes = ratingVotes + } + + // MARK: - Body + + var body: some View { + Section(L10n.details) { + if let provider { + LabeledContent(L10n.provider, value: provider) + } + + if let language { + LabeledContent(L10n.language, value: language) + } + + if let width, let height { + LabeledContent( + L10n.dimensions, + value: "\(width) x \(height)" + ) + } + + if let index { + LabeledContent(L10n.index, value: index.description) + } + } + + if let rating { + Section(L10n.ratings) { + LabeledContent(L10n.rating, value: rating.formatted(.number.precision(.fractionLength(2)))) + + if let ratingVotes { + LabeledContent(L10n.votes, value: ratingVotes, format: .number) + } + } + } + + if let url { + Section { + ChevronButton( + L10n.imageSource, + external: true + ) { + UIApplication.shared.open(url) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift new file mode 100644 index 00000000..6fc1c89f --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift @@ -0,0 +1,43 @@ +// +// 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 ItemImageDetailsView { + + struct HeaderSection: View { + + // MARK: - Image Info + + let imageSource: ImageSource + let posterType: PosterDisplayType + + // MARK: - Body + + var body: some View { + Section { + ImageView(imageSource) + .placeholder { _ in + Image(systemName: "photo") + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + } + .scaledToFit() + .frame(maxHeight: 300) + .posterStyle(posterType) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift new file mode 100644 index 00000000..a8b97c3f --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift @@ -0,0 +1,161 @@ +// +// 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 + +struct ItemImageDetailsView: View { + + // MARK: - Editing State + + @Environment(\.isEditing) + private var isEditing + + // MARK: - State, Observed, & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ItemImagesViewModel + + // MARK: - Image Variable + + private let imageSource: ImageSource + + // MARK: - Description Variables + + private let index: Int? + private let width: Int? + private let height: Int? + private let language: String? + private let provider: String? + private let rating: Double? + private let ratingVotes: Int? + + // MARK: - Image Actions + + private let onSave: (() -> Void)? + private let onDelete: (() -> Void)? + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.image) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + + if !isEditing, let onSave { + Button(L10n.save) { + onSave() + } + .buttonStyle(.toolbarPill) + } + } + .errorMessage($error) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + List { + HeaderSection( + imageSource: imageSource, + posterType: height ?? 0 > width ?? 0 ? .portrait : .landscape + ) + + DetailsSection( + url: imageSource.url, + index: index, + language: language, + width: width, + height: height, + provider: provider, + rating: rating, + ratingVotes: ratingVotes + ) + + if isEditing, let onDelete { + DeleteButton { + onDelete() + } + } + } + } +} + +extension ItemImageDetailsView { + + // Initialize as a Local Server Image + + init( + viewModel: ItemImagesViewModel, + imageInfo: ImageInfo + ) { + self.viewModel = viewModel + self.imageSource = imageInfo.itemImageSource( + itemID: viewModel.item.id!, + client: viewModel.userSession.client + ) + self.index = imageInfo.imageIndex + self.width = imageInfo.width + self.height = imageInfo.height + self.language = nil + self.provider = nil + self.rating = nil + self.ratingVotes = nil + self.onSave = nil + self.onDelete = { + viewModel.send(.deleteImage(imageInfo)) + } + } + + // Initialize as a Remote Search Image + + init( + viewModel: ItemImagesViewModel, + remoteImageInfo: RemoteImageInfo + ) { + self.viewModel = viewModel + self.imageSource = ImageSource(url: remoteImageInfo.url?.url) + self.index = nil + self.width = remoteImageInfo.width + self.height = remoteImageInfo.height + self.language = remoteImageInfo.language + self.provider = remoteImageInfo.providerName + self.rating = remoteImageInfo.communityRating + self.ratingVotes = remoteImageInfo.voteCount + self.onSave = { + viewModel.send(.setImage(remoteImageInfo)) + } + self.onDelete = nil + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift new file mode 100644 index 00000000..65601dea --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift @@ -0,0 +1,199 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ItemImagesView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @StateObject + var viewModel: ItemImagesViewModel + + // MARK: - Dialog State + + @State + private var selectedType: ImageType? + @State + private var isFilePickerPresented = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + imageView + case .initial: + DelayedProgressView() + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + } + .navigationTitle(L10n.images) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.refresh) + } + .navigationBarCloseButton { + router.dismiss() + } + .fileImporter( + isPresented: $isFilePickerPresented, + allowedContentTypes: [.png, .jpeg, .heic], + allowsMultipleSelection: false + ) { + switch $0 { + case let .success(urls): + if let file = urls.first, let type = selectedType { + viewModel.send(.uploadFile(file: file, type: type)) + selectedType = nil + } + case let .failure(fileError): + error = fileError + selectedType = nil + } + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: () + case let .error(eventError): + self.error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Image View + + @ViewBuilder + private var imageView: some View { + ScrollView { + ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in + Section { + imageScrollView(for: imageType) + + RowDivider() + .padding(.vertical, 16) + } header: { + sectionHeader(for: imageType) + } + } + } + } + + // MARK: - Image Scroll View + + @ViewBuilder + private func imageScrollView(for imageType: ImageType) -> some View { + let images = viewModel.images[imageType] ?? [] + + if images.isNotEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(images, id: \.self) { imageInfo in + imageButton(imageInfo: imageInfo) { + router.route( + to: .itemImageDetails( + viewModel: viewModel, + imageInfo: imageInfo + ) + ) + } + } + } + .edgePadding(.horizontal) + } + } + } + + // MARK: - Section Header + + @ViewBuilder + private func sectionHeader(for imageType: ImageType) -> some View { + HStack { + Text(imageType.displayTitle) + .font(.headline) + + Spacer() + + Menu(L10n.options, systemImage: "plus") { + Button(L10n.search, systemImage: "magnifyingglass") { + router.route(to: .addItemImage(viewModel: viewModel, imageType: imageType)) + } + + Divider() + + Button(L10n.uploadFile, systemImage: "document.badge.plus") { + selectedType = imageType + isFilePickerPresented = true + } + + Button(L10n.uploadPhoto, systemImage: "photo.badge.plus") { + router.route(to: .itemImageSelector(viewModel: viewModel, imageType: imageType)) + } + } + .font(.body) + .labelStyle(.iconOnly) + .fontWeight(.semibold) + .foregroundStyle(accentColor) + } + .edgePadding(.horizontal) + } + + // MARK: - Image Button + + // TODO: instead of using `posterStyle`, should be sized based on + // the image type and just ignore and poster styling + @ViewBuilder + private func imageButton( + imageInfo: ImageInfo, + onSelect: @escaping () -> Void + ) -> some View { + Button(action: onSelect) { + ZStack { + Color.secondarySystemFill + + ImageView( + imageInfo.itemImageSource( + itemID: viewModel.item.id!, + client: viewModel.userSession.client + ) + ) + .placeholder { _ in + Image(systemName: "photo") + } + .failure { + Image(systemName: "photo") + } + .pipeline(.Swiftfin.other) + } + .posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape) + .frame(maxHeight: 150) + .posterShadow() + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift new file mode 100644 index 00000000..35f120b4 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift @@ -0,0 +1,61 @@ +// +// 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 Mantis +import SwiftUI + +// TODO: cleanup alongside `UserProfileImageCropView` + +struct ItemPhotoCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @Router + private var router + + @ObservedObject + var viewModel: ItemImagesViewModel + + // MARK: - Image Variable + + let image: UIImage + let type: ImageType + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + PhotoCropView( + isSaving: viewModel.backgroundStates.contains(.updating), + image: image, + cropShape: .rect, + presetRatio: .canUseMultiplePresetFixedRatio() + ) { + viewModel.send(.uploadImage(image: $0, type: type)) + } onCancel: { + router.dismiss() + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .interactiveDismissDisabled(viewModel.backgroundStates.contains(.updating)) + .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .updated: + router.dismiss() + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift new file mode 100644 index 00000000..07f574af --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift @@ -0,0 +1,55 @@ +// +// 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 + +struct ItemImagePicker: View { + + // MARK: - Observed, & Environment Objects + + @Router + private var router + + @StateObject + var viewModel: ItemImagesViewModel + + let type: ImageType + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + PhotoPickerView { + router.route( + to: .cropItemImage( + viewModel: viewModel, + image: $0, + type: type + ) + ) + } onCancel: { + router.dismiss() + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismiss() + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift new file mode 100644 index 00000000..ee9d5199 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift @@ -0,0 +1,141 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct AddItemElementView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment & Observed Objects + + @Router + private var router + + @ObservedObject + var viewModel: ItemEditorViewModel + + // MARK: - Elements Variables + + let type: ItemArrayElements + + @State + private var id: String? + @State + private var name: String = "" + @State + private var personKind: PersonKind = .unknown + @State + private var personRole: String = "" + + // MARK: - Trie Data Loaded + + @State + private var loaded: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Name is Valid + + private var isValid: Bool { + name.isNotEmpty + } + + // MARK: - Name Already Exists + + private var itemAlreadyExists: Bool { + viewModel.trie.contains(key: name.localizedLowercase) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + ErrorView(error: error) + } + } + .navigationTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.loading) { + ProgressView() + } + + Button(L10n.save) { + viewModel.send(.add([type.createElement( + name: name, + id: id, + personRole: personRole.isEmpty ? (personKind == .unknown ? nil : personKind.rawValue) : personRole, + personKind: personKind + )])) + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + .onFirstAppear { + viewModel.send(.load) + } + .onChange(of: name) { _ in + if !viewModel.backgroundStates.contains(.loading) { + viewModel.send(.search(name)) + } + } + .onReceive(viewModel.events) { event in + switch event { + case .updated: + UIDevice.feedback(.success) + router.dismiss() + case .loaded: + loaded = true + viewModel.send(.search(name)) + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + List { + NameInput( + name: $name, + personKind: $personKind, + personRole: $personRole, + type: type, + itemAlreadyExists: itemAlreadyExists + ) + + SearchResultsSection( + name: $name, + id: $id, + type: type, + population: viewModel.matches, + isSearching: viewModel.backgroundStates.contains(.searching) + ) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift new file mode 100644 index 00000000..b4ab8fc2 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift @@ -0,0 +1,87 @@ +// +// 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 AddItemElementView { + + struct NameInput: View { + + // MARK: - Element Variables + + @Binding + var name: String + @Binding + var personKind: PersonKind + @Binding + var personRole: String + + let type: ItemArrayElements + let itemAlreadyExists: Bool + + // MARK: - Body + + var body: some View { + nameView + + if type == .people { + personView + } + } + + // MARK: - Name View + + private var nameView: some View { + Section { + TextField(L10n.name, text: $name) + .autocorrectionDisabled() + } header: { + Text(L10n.name) + } footer: { + if name.isEmpty || name == "" { + Label( + L10n.required, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } else { + if itemAlreadyExists { + Label( + L10n.existsOnServer, + systemImage: "checkmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .green)) + } else { + Label( + L10n.willBeCreatedOnServer, + systemImage: "checkmark.seal.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) + } + } + } + } + + // MARK: - Person View + + var personView: some View { + Section { + Picker(L10n.type, selection: $personKind) { + ForEach(PersonKind.allCases, id: \.self) { kind in + Text(kind.displayTitle).tag(kind) + } + } + if personKind == PersonKind.actor { + TextField(L10n.role, text: $personRole) + .autocorrectionDisabled() + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift new file mode 100644 index 00000000..51f639f7 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift @@ -0,0 +1,110 @@ +// +// 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 AddItemElementView { + + struct SearchResultsSection: View { + + // MARK: - Element Variables + + @Binding + var name: String + @Binding + var id: String? + + // MARK: - Element Search Variables + + let type: ItemArrayElements + let population: [Element] + let isSearching: Bool + + // MARK: - Body + + var body: some View { + if name.isNotEmpty { + Section { + if population.isNotEmpty { + resultsView + .animation(.easeInOut, value: population.count) + } else if !isSearching { + noResultsView + .transition(.opacity) + .animation(.easeInOut, value: population.count) + } + } header: { + HStack { + Text(L10n.existingItems) + if isSearching { + DelayedProgressView() + } else { + Text("-") + Text(population.count.description) + } + } + .animation(.easeInOut, value: isSearching) + } + } + } + + // MARK: - No Results View + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + + // MARK: - Results View + + private var resultsView: some View { + ForEach(population, id: \.self) { result in + Button { + name = type.getName(for: result) + id = type.getId(for: result) + } label: { + labelView(result) + } + .foregroundStyle(.primary) + .disabled(name == type.getName(for: result)) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation(.easeInOut, value: population.count) + } + } + + // MARK: - Label View + + @ViewBuilder + private func labelView(_ match: Element) -> some View { + switch type { + case .people: + let person = match as! BaseItemPerson + HStack { + ZStack { + Color.clear + ImageView(person.portraitImageSources(maxWidth: 30, quality: 90)) + .failure { + SystemImageContentView(systemName: "person.fill") + } + } + .posterStyle(.portrait) + .frame(width: 30, height: 90) + .padding(.horizontal) + + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + default: + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift new file mode 100644 index 00000000..8d25bcbf --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift @@ -0,0 +1,113 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditItemElementView { + + struct EditItemElementRow: View { + + // MARK: - Enviroment Variables + + @Environment(\.isEditing) + var isEditing + @Environment(\.isSelected) + var isSelected + + // MARK: - Metadata Variables + + let item: Element + let type: ItemArrayElements + + // MARK: - Row Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + ListRow { + if type == .people { + personImage + } + } content: { + rowContent + } + .onSelect(perform: onSelect) + .isSeparatorVisible(false) + .swipeActions { + Button(L10n.delete, systemImage: "trash", action: onDelete) + .tint(.red) + } + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + Text(type.getName(for: item)) + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary + ) + .font(.headline) + .lineLimit(1) + + if type == .people { + let person = (item as! BaseItemPerson) + + LabeledContent( + person.type?.displayTitle ?? .emptyDash, + value: person.role ?? .emptyDash + ) + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary, + .secondary + ) + .font(.subheadline) + .lineLimit(1) + } + } + + if isEditing { + Spacer() + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + } + } + } + + // MARK: - Person Image + + @ViewBuilder + private var personImage: some View { + let person = (item as! BaseItemPerson) + + ZStack { + Color.clear + + ImageView(person.portraitImageSources(maxWidth: 30, quality: 90)) + .failure { + SystemImageContentView(systemName: "person.fill") + } + } + .posterStyle(.portrait) + .frame(width: 30, height: 90) + .posterShadow() + .padding(.horizontal) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift new file mode 100644 index 00000000..7e610850 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift @@ -0,0 +1,276 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: move away from the `route` method for adding a new item + +struct EditItemElementView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @ObservedObject + var viewModel: ItemEditorViewModel + + @Router + private var router + + // MARK: - Elements + + @State + private var elements: [Element] + + // MARK: - Type & Route + + private let type: ItemArrayElements + private let route: (NavigationCoordinator.Router, ItemEditorViewModel) -> Void + + // MARK: - Dialog States + + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingDeleteSelectionConfirmation = false + + // MARK: - Editing States + + @State + private var selectedElements: Set = [] + @State + private var isEditing: Bool = false + @State + private var isReordering: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init( + viewModel: ItemEditorViewModel, + type: ItemArrayElements, + route: @escaping (NavigationCoordinator.Router, ItemEditorViewModel) -> Void + ) { + self.viewModel = viewModel + self.type = type + self.route = route + self.elements = type.getElement(for: viewModel.item) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing || isReordering) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing || isReordering { + Button(L10n.cancel) { + if isEditing { + isEditing.toggle() + } + if isReordering { + elements = type.getElement(for: viewModel.item) + isReordering.toggle() + } + UIDevice.impact(.light) + selectedElements.removeAll() + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedElements.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + if isReordering { + Button(L10n.save) { + viewModel.send(.reorder(elements)) + isReordering = false + } + .buttonStyle(.toolbarPill) + .disabled(type.getElement(for: viewModel.item) == elements) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || isReordering + ) { + Button(L10n.add, systemImage: "plus") { + route(router.router, viewModel) + } + + if elements.isNotEmpty == true { + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + + Button(L10n.reorder, systemImage: "arrow.up.arrow.down") { + isReordering = true + } + } + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + default: + break + } + } + .errorMessage($error) + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteConfirmationActions + } message: { + Text(L10n.deleteItemConfirmation) + } + .onNotification(.itemMetadataDidChange) { _ in + self.elements = type.getElement(for: self.viewModel.item) + } + } + + // MARK: - Select/Remove All Button + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected = selectedElements.count == (elements.count) + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + selectedElements = isAllSelected ? [] : Set(elements) + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.load) + } + } + + // MARK: - Content View + + private var contentView: some View { + List { + InsetGroupedListHeader(type.displayTitle, description: type.description) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if elements.isNotEmpty { + ForEach(elements, id: \.self) { element in + EditItemElementRow( + item: element, + type: type, + onSelect: { + if isEditing { + selectedElements.toggle(value: element) + } + }, + onDelete: { + selectedElements.toggle(value: element) + isPresentingDeleteConfirmation = true + } + ) + .isEditing(isEditing) + .isSelected(selectedElements.contains(element)) + .listRowInsets(.edgeInsets) + } + .onMove { source, destination in + guard isReordering else { return } + elements.move(fromOffsets: source, toOffset: destination) + } + } else { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } + } + .listStyle(.plain) + .environment(\.editMode, isReordering ? .constant(.active) : .constant(.inactive)) + } + + // MARK: - Delete Selected Confirmation Actions + + @ViewBuilder + private var deleteSelectedConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + let elementsToRemove = elements.filter { selectedElements.contains($0) } + viewModel.send(.remove(elementsToRemove)) + selectedElements.removeAll() + isEditing = false + } + } + + // MARK: - Delete Single Confirmation Actions + + @ViewBuilder + private var deleteConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let elementToRemove = selectedElements.first, selectedElements.count == 1 { + viewModel.send(.remove([elementToRemove])) + selectedElements.removeAll() + isEditing = false + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift new file mode 100644 index 00000000..d70a4e46 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift @@ -0,0 +1,55 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct DateSection: View { + + @Binding + var item: BaseItemDto + + let itemType: BaseItemKind + + var body: some View { + Section(L10n.dates) { + DatePicker( + L10n.dateAdded, + selection: $item.dateCreated.coalesce(.now), + displayedComponents: .date + ) + + DatePicker( + itemType == .person ? L10n.birthday : L10n.releaseDate, + selection: $item.premiereDate.coalesce(.now), + displayedComponents: .date + ) + + if itemType == .series || itemType == .person { + DatePicker( + itemType == .person ? L10n.dateOfDeath : L10n.endDate, + selection: $item.endDate.coalesce(.now), + displayedComponents: .date + ) + } + } + + Section(L10n.year) { + TextField( + itemType == .person ? L10n.birthYear : L10n.year, + value: $item.productionYear, + format: .number.grouping(.never) + ) + .keyboardType(.numberPad) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift new file mode 100644 index 00000000..2ea9b720 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift @@ -0,0 +1,61 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct DisplayOrderSection: View { + + @Binding + var item: BaseItemDto + + let itemType: BaseItemKind + + var body: some View { + Section(L10n.displayOrder) { + switch itemType { + case .boxSet: + Picker( + L10n.displayOrder, + selection: $item.displayOrder + .coalesce("") + .map( + getter: { BoxSetDisplayOrder(rawValue: $0) ?? .dateModified }, + setter: { $0.rawValue } + ) + ) { + ForEach(BoxSetDisplayOrder.allCases) { order in + Text(order.displayTitle).tag(order) + } + } + + case .series: + Picker( + L10n.displayOrder, + selection: $item.displayOrder + .coalesce("") + .map( + getter: { SeriesDisplayOrder(rawValue: $0) ?? .aired }, + setter: { $0.rawValue } + ) + ) { + ForEach(SeriesDisplayOrder.allCases) { order in + Text(order.displayTitle).tag(order) + } + } + + default: + EmptyView() + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift new file mode 100644 index 00000000..73c6b5f5 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift @@ -0,0 +1,57 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct EpisodeSection: View { + + @Binding + var item: BaseItemDto + + // MARK: - Body + + var body: some View { + Section(L10n.season) { + + // MARK: - Season Number + + ChevronButton( + L10n.season, + subtitle: item.parentIndexNumber?.description, + description: L10n.enterSeasonNumber + ) { + TextField( + L10n.season, + value: $item.parentIndexNumber, + format: .number + ) + .keyboardType(.numberPad) + } + + // MARK: - Episode Number + + ChevronButton( + L10n.episode, + subtitle: item.indexNumber?.description, + description: L10n.enterEpisodeNumber + ) { + TextField( + L10n.episode, + value: $item.indexNumber, + format: .number + ) + .keyboardType(.numberPad) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift new file mode 100644 index 00000000..8f1c0f26 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift @@ -0,0 +1,34 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct LocalizationSection: View { + + @Binding + var item: BaseItemDto + + var body: some View { + Section(L10n.metadataPreferences) { + CulturePicker( + L10n.language, + twoLetterISOLanguageName: $item.preferredMetadataLanguage + ) + + CountryPicker( + L10n.country, + twoLetterISORegion: $item.preferredMetadataCountryCode + ) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift new file mode 100644 index 00000000..ee2b24ae --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift @@ -0,0 +1,42 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct LockMetadataSection: View { + + @Binding + var item: BaseItemDto + + var body: some View { + Section(L10n.lockedFields) { + Toggle( + L10n.lockAllFields, + isOn: $item.lockData.coalesce(false) + ) + } + + if item.lockData != true { + Section { + ForEach(MetadataField.allCases, id: \.self) { field in + Toggle( + field.displayTitle, + isOn: $item.lockedFields + .coalesce([]) + .contains(field) + ) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift new file mode 100644 index 00000000..c9a580f8 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift @@ -0,0 +1,35 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct MediaFormatSection: View { + + @Binding + var item: BaseItemDto + + var body: some View { + Section(L10n.format) { + TextField( + L10n.originalAspectRatio, + value: $item.aspectRatio, + format: .nilIfEmptyString + ) + + Video3DFormatPicker( + title: L10n.format3D, + selectedFormat: $item.video3DFormat + ) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift new file mode 100644 index 00000000..a8c8f27e --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift @@ -0,0 +1,64 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct OverviewSection: View { + + // MARK: - Metadata Variables + + @Binding + var item: BaseItemDto + + let itemType: BaseItemKind + + // MARK: - Show Tagline + + private var showTaglines: Bool { + [ + BaseItemKind.movie, + .series, + .audioBook, + .book, + .audio, + ].contains(itemType) + } + + // MARK: - Body + + var body: some View { + if showTaglines { + // There doesn't seem to be a usage anywhere of more than 1 tagline? + Section(L10n.taglines) { + TextField( + L10n.tagline, + value: $item.taglines + .map( + getter: { $0 == nil ? "" : $0!.first }, + setter: { $0 == nil ? [] : [$0!] } + ), + format: .nilIfEmptyString + ) + } + } + + Section(L10n.overview) { + TextEditor(text: $item.overview.coalesce("")) + .onAppear { + // Workaround for iOS 17 and earlier bug + // where the row height won't be set properly + item.overview = item.overview + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift new file mode 100644 index 00000000..f6419d28 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift @@ -0,0 +1,30 @@ +// +// 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 EditMetadataView { + + struct ParentalRatingSection: View { + + // MARK: - Item + + @Binding + var item: BaseItemDto + + // MARK: - Body + + var body: some View { + Section(L10n.parentalRating) { + ParentalRatingPicker(L10n.officialRating, name: $item.officialRating) + ParentalRatingPicker(L10n.customRating, name: $item.customRating) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift new file mode 100644 index 00000000..bba94685 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift @@ -0,0 +1,73 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct ReviewsSection: View { + + @Binding + var item: BaseItemDto + + // MARK: - Body + + var body: some View { + Section(L10n.reviews) { + + // MARK: - Critics Rating + + ChevronButton( + L10n.critics, + subtitle: item.criticRating + .map { FloatingPointFormatStyle.number + .precision(.fractionLength(0 ... 2)).format($0) + } ?? .emptyDash, + description: L10n.criticRatingDescription + ) { + TextField( + L10n.rating, + value: $item.criticRating, + format: .number + ) + .keyboardType(.decimalPad) + .onChange(of: item.criticRating) { _ in + if let rating = item.criticRating { + item.criticRating = min(max(rating, 0), 100) + } + } + } + + // MARK: - Community Rating + + ChevronButton( + L10n.community, + subtitle: item.communityRating + .map { FloatingPointFormatStyle.number + .precision(.fractionLength(0 ... 2)).format($0) + } ?? .emptyDash, + description: L10n.communityRatingDescription + ) { + TextField( + L10n.rating, + value: $item.communityRating, + format: .number + ) + .keyboardType(.decimalPad) + .onChange(of: item.communityRating) { _ in + if let rating = item.communityRating { + item.communityRating = min(max(rating, 0), 10) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift new file mode 100644 index 00000000..54d7afde --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift @@ -0,0 +1,147 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct SeriesSection: View { + + @Binding + private var item: BaseItemDto + + @State + private var tempRunTime: Int? + + // MARK: - Initializer + + init(item: Binding) { + self._item = item + self.tempRunTime = Int(ServerTicks(item.wrappedValue.runTimeTicks ?? 0).minutes) + } + + // MARK: - Body + + var body: some View { + + Section(L10n.series) { + seriesStatusView + } + + Section(L10n.episodes) { + airTimeView + + runTimeView + } + + Section(L10n.dayOfWeek) { + airDaysView + } + } + + // MARK: - Series Status View + + @ViewBuilder + private var seriesStatusView: some View { + Picker( + L10n.status, + selection: $item.status + .coalesce("") + .map( + getter: { SeriesStatus(rawValue: $0) ?? .continuing }, + setter: { $0.rawValue } + ) + ) { + ForEach(SeriesStatus.allCases, id: \.self) { status in + Text(status.displayTitle).tag(status) + } + } + } + + // MARK: - Air Time View + + @ViewBuilder + private var airTimeView: some View { + DatePicker( + L10n.airTime, + selection: $item.airTime + .coalesce("00:00") + .map( + getter: { parseAirTimeToDate($0) }, + setter: { formatDateToString($0) } + ), + displayedComponents: .hourAndMinute + ) + } + + // MARK: - Air Days View + + @ViewBuilder + private var airDaysView: some View { + ForEach(DayOfWeek.allCases, id: \.self) { field in + Toggle( + field.displayTitle, + isOn: $item.airDays + .coalesce([]) + .contains(field) + ) + } + } + + // MARK: - Run Time View + + @ViewBuilder + private var runTimeView: some View { + ChevronButton( + L10n.runtime, + subtitle: ServerTicks(item.runTimeTicks ?? 0) + .seconds.formatted(.hourMinute), + description: L10n.episodeRuntimeDescription + ) { + TextField( + L10n.minutes, + value: $tempRunTime + .coalesce(0) + .min(0), + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if let tempRunTime, tempRunTime != 0 { + item.runTimeTicks = ServerTicks(minutes: tempRunTime).ticks + } else { + item.runTimeTicks = nil + } + } onCancel: { + if let originalRunTime = item.runTimeTicks { + tempRunTime = Int(ServerTicks(originalRunTime).minutes) + } else { + tempRunTime = nil + } + } + } + + // MARK: - Parse AirTime to Date + + private func parseAirTimeToDate(_ airTime: String) -> Date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm" + return dateFormatter.date(from: airTime) ?? Date() + } + + // MARK: - Format Date to String + + private func formatDateToString(_ date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm" + return dateFormatter.string(from: date) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift new file mode 100644 index 00000000..42f2fc77 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift @@ -0,0 +1,46 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +extension EditMetadataView { + + struct TitleSection: View { + + @Binding + var item: BaseItemDto + + var body: some View { + Section(L10n.title) { + TextField( + L10n.title, + value: $item.name, + format: .nilIfEmptyString + ) + } + + Section(L10n.originalTitle) { + TextField( + L10n.originalTitle, + value: $item.originalTitle, + format: .nilIfEmptyString + ) + } + + Section(L10n.sortTitle) { + TextField( + L10n.sortTitle, + value: $item.forcedSortName, + format: .nilIfEmptyString + ) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift new file mode 100644 index 00000000..0de75825 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift @@ -0,0 +1,130 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +struct EditMetadataView: View { + + // MARK: - Observed & Environment Objects + + @Router + private var router + + @ObservedObject + private var viewModel: ItemEditorViewModel + + // MARK: - Metadata Variables + + @Binding + var item: BaseItemDto + + @State + private var tempItem: BaseItemDto + + private let itemType: BaseItemKind + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ItemEditorViewModel) { + self.viewModel = viewModel + self._item = Binding(get: { viewModel.item }, set: { viewModel.item = $0 }) + self._tempItem = State(initialValue: viewModel.item) + self.itemType = viewModel.item.type! + } + + // MARK: - Body + + @ViewBuilder + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationTitle(L10n.metadata) + .navigationBarTitleDisplayMode(.inline) + .topBarTrailing { + Button(L10n.save) { + item = tempItem + viewModel.send(.update(tempItem)) + router.dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.item == tempItem) + } + .navigationBarCloseButton { + router.dismiss() + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + default: + break + } + } + .errorMessage($error) + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.load) + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + Form { + TitleSection(item: $tempItem) + + DateSection( + item: $tempItem, + itemType: itemType + ) + + if itemType == .series { + SeriesSection(item: $tempItem) + } else if itemType == .episode { + EpisodeSection(item: $tempItem) + } + + OverviewSection( + item: $tempItem, + itemType: itemType + ) + + ReviewsSection(item: $tempItem) + + ParentalRatingSection(item: $tempItem) + + if [.movie, .episode].contains(itemType) { + MediaFormatSection(item: $tempItem) + } + + LocalizationSection(item: $tempItem) + + LockMetadataSection(item: $tempItem) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemSubtitles/Components/ItemSubtitleButton.swift b/Swiftfin/Views/ItemEditorView/ItemSubtitles/Components/ItemSubtitleButton.swift new file mode 100644 index 00000000..4f028102 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemSubtitles/Components/ItemSubtitleButton.swift @@ -0,0 +1,90 @@ +// +// 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 ItemSubtitlesView { + + struct SubtitleButton: View { + + // MARK: - Environment Variables + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + // MARK: - Subtitle Variables + + private let subtitle: MediaStream + + // MARK: - Row Actions + + private let action: () -> Void + private let deleteAction: (() -> Void)? + + // MARK: - Body + + var body: some View { + ListRow {} content: { + rowContent + } + .onSelect(perform: action) + .isSeparatorVisible(false) + .ifLet(deleteAction) { button, deleteAction in + button + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: deleteAction + ) + .tint(.red) + } + } + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + Text(subtitle.displayTitle ?? L10n.unknown) + .foregroundStyle(isEditing && !isSelected ? .secondary : .primary) + + Spacer() + + ListRowCheckbox() + } + } + } +} + +extension ItemSubtitlesView.SubtitleButton { + + // MARK: - Initialize + + init( + _ subtitle: MediaStream, + action: @escaping () -> Void, + deleteAction: @escaping () -> Void + ) { + self.subtitle = subtitle + self.action = action + self.deleteAction = deleteAction + } + + // MARK: - Initialize without Delete Action + + init(_ subtitle: MediaStream, action: @escaping () -> Void) { + self.subtitle = subtitle + self.action = action + self.deleteAction = nil + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/Components/RemoteSubtitleButton.swift b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/Components/RemoteSubtitleButton.swift new file mode 100644 index 00000000..ab92066c --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/Components/RemoteSubtitleButton.swift @@ -0,0 +1,64 @@ +// +// 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 + +struct SubtitleResultRow: View { + + // MARK: - Environment Variables + + @Environment(\.isSelected) + private var isSelected + + // MARK: - Subtitle Variable + + let subtitle: RemoteSubtitleInfo + + // MARK: - Subtitle Action + + let action: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: action) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(subtitle.name ?? L10n.unknown) + .font(.headline) + .fontWeight(.semibold) + + LabeledContent(L10n.language, value: subtitle.threeLetterISOLanguageName ?? L10n.unknown) + + if let downloadCount = subtitle.downloadCount { + LabeledContent(L10n.downloads, value: downloadCount.description) + } + + if let rating = subtitle.communityRating { + LabeledContent(L10n.communityRating, value: String(format: "%.1f", rating)) + } + + if let author = subtitle.author { + LabeledContent(L10n.author, value: author) + } + + if let format = subtitle.format { + LabeledContent(L10n.format, value: format) + } + } + .foregroundStyle(isSelected ? .primary : .secondary, .secondary) + .font(.caption) + + Spacer() + + ListRowCheckbox() + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/ItemSubtitleSearchView.swift b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/ItemSubtitleSearchView.swift new file mode 100644 index 00000000..179ca4a4 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleSearchView/ItemSubtitleSearchView.swift @@ -0,0 +1,160 @@ +// +// 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 + +struct ItemSubtitleSearchView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - ViewModel + + @ObservedObject + private var viewModel: SubtitleEditorViewModel + + // MARK: - Selected Subtitles + + @State + private var selectedSubtitles: Set = [] + + // MARK: - Search Properties + + /// Default to user's language + @State + private var language: String? = Locale.current.language.languageCode?.identifier(.alpha3) + + @State + private var isPerfectMatch = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: SubtitleEditorViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .initial, .content: + contentView + case let .error(error): + ErrorView(error: error) + } + } + .navigationTitle(L10n.search) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.search(language: language)) + } + .onReceive(viewModel.events) { event in + switch event { + case .deleted: + return + case .uploaded: + router.dismiss() + case let .error(eventError): + error = eventError + } + } + .errorMessage($error) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.isNotEmpty { + ProgressView() + } + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel, role: .cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill) + } else { + Button(L10n.save) { + guard selectedSubtitles.isNotEmpty else { + error = JellyfinAPIError(L10n.noItemSelected) + return + } + + viewModel.send(.set(selectedSubtitles)) + } + .buttonStyle(.toolbarPill) + .disabled(selectedSubtitles.isEmpty) + } + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + Form { + searchSection + resultsSection + } + } + + // MARK: - Search Section + + @ViewBuilder + private var searchSection: some View { + Section(L10n.options) { + CulturePicker(L10n.language, threeLetterISOLanguageName: $language) + .onChange(of: language) { _ in + if let language { + viewModel.send(.search(language: language, isPerfectMatch: isPerfectMatch)) + } + } + + Toggle(L10n.perfectMatch, isOn: $isPerfectMatch) + .onChange(of: isPerfectMatch) { _ in + if let language { + viewModel.send(.search(language: language, isPerfectMatch: isPerfectMatch)) + } + } + } + } + + // MARK: - Results Section + + @ViewBuilder + private var resultsSection: some View { + Section(L10n.search) { + if viewModel.searchResults.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + ForEach(viewModel.searchResults, id: \.id) { subtitle in + let isSelected = subtitle.id.map { selectedSubtitles.contains($0) } ?? false + + SubtitleResultRow(subtitle: subtitle) { + guard let subtitleID = subtitle.id else { + return + } + + selectedSubtitles.toggle(value: subtitleID) + } + .foregroundStyle(isSelected ? .primary : .secondary, .secondary) + .isSelected(isSelected) + .isEditing(true) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleUploadView/ItemSubtitleUploadView.swift b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleUploadView/ItemSubtitleUploadView.swift new file mode 100644 index 00000000..a2d9e954 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitleUploadView/ItemSubtitleUploadView.swift @@ -0,0 +1,181 @@ +// +// 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 JellyfinAPI +import SwiftUI +import UniformTypeIdentifiers + +struct ItemSubtitleUploadView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - Accent Color + + @Default(.accentColor) + private var accentColor + + // MARK: - ViewModel + + @ObservedObject + private var viewModel: SubtitleEditorViewModel + + // MARK: - Supported Subtitles for Upload + + private let validSubtitleFormats = SubtitleFormat.allCases.filter(\.isText).compactMap(\.utType) + + // MARK: - File Picker States + + @State + private var isPresentingFileUpload = false + + // MARK: - Subtitle Data + + @State + private var subtitleFile: URL? + @State + private var subtitleData: Data? + @State + private var subtitleFormat: SubtitleFormat? + + // MARK: - Subtitle Properties + + /// Default to user's language + @State + private var language: String? = Locale.current.language.languageCode?.identifier(.alpha3) + @State + private var isForced = false + @State + private var isHearingImpaired = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: SubtitleEditorViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.subtitle) + .navigationBarTitleDisplayMode(.inline) + .onReceive(viewModel.events) { event in + switch event { + case .deleted: + return + case .uploaded: + router.dismiss() + case let .error(eventError): + error = eventError + } + } + .errorMessage($error) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel, role: .cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill) + } else { + Button(L10n.save) { + uploadSubtitle() + } + .buttonStyle(.toolbarPill) + .disabled(subtitleData == nil) + } + } + .fileImporter( + isPresented: $isPresentingFileUpload, + allowedContentTypes: validSubtitleFormats, + onCompletion: selectSubtitleFile + ) + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + Form { + Section(L10n.options) { + CulturePicker(L10n.language, threeLetterISOLanguageName: $language) + + Toggle(L10n.forced, isOn: $isForced) + Toggle(L10n.hearingImpaired, isOn: $isHearingImpaired) + } + + Section(L10n.file) { + Text(subtitleFile?.lastPathComponent ?? L10n.noItemSelected) + .foregroundStyle(.secondary) + } + + Section { + ListRowButton(subtitleData == nil ? L10n.uploadFile : L10n.replaceSubtitle) { + isPresentingFileUpload = true + } + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + } + + // MARK: - Select Subtitle File + + private func selectSubtitleFile(_ result: Result) { + do { + let fileURL = try result.get() + self.subtitleFile = fileURL + + if let format = SubtitleFormat(url: fileURL) { + self.subtitleFormat = format + self.subtitleData = try Data(contentsOf: fileURL) + } else { + error = JellyfinAPIError(L10n.invalidFormat) + } + } catch { + self.error = error + } + } + + // MARK: - Upload Subtitle + + private func uploadSubtitle() { + guard let subtitleData = subtitleData, + let subtitleFormat = subtitleFormat + else { + error = JellyfinAPIError(L10n.noItemSelected) + return + } + + let encodedData = subtitleData.base64EncodedString() + + if let language { + let subtitle = UploadSubtitleDto( + data: encodedData, + format: subtitleFormat.fileExtension, + isForced: isForced, + isHearingImpaired: isHearingImpaired, + language: language + ) + + viewModel.send(.upload(subtitle)) + } else { + error = JellyfinAPIError(L10n.noItemSelected) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitlesView.swift b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitlesView.swift new file mode 100644 index 00000000..6c861087 --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemSubtitles/ItemSubtitlesView.swift @@ -0,0 +1,220 @@ +// +// 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 + +struct ItemSubtitlesView: View { + + // MARK: - Router + + @Router + private var router + + // MARK: - ViewModel + + @StateObject + private var viewModel: SubtitleEditorViewModel + + // MARK: - Edit Mode + + @State + private var isEditing = false + + // MARK: - Deletion Dialog States + + @State + private var selectedSubtitles: Set = [] + @State + private var isPresentingDeleteConfirmation = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Item has Subtitles + + private var hasSubtitles: Bool { + viewModel.externalSubtitles.isNotEmpty || viewModel.internalSubtitles.isNotEmpty + } + + // MARK: - All Subtitles Selected + + private var isAllSelected: Bool { + selectedSubtitles.count == viewModel.externalSubtitles.count + } + + // MARK: - Initializer + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: .init(item: item)) + } + + // MARK: - Toggle All Selection + + private func toggleAllSelection() { + selectedSubtitles = isAllSelected ? [] : Set(viewModel.externalSubtitles) + } + + // MARK: - Cancel Editing + + private func cancelEditing() { + isEditing = false + UIDevice.impact(.light) + selectedSubtitles.removeAll() + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case let .error(error): + ErrorView(error: error) + default: + contentView + } + } + .navigationTitle(L10n.subtitles) + .navigationBarBackButtonHidden(isEditing) + .navigationBarTitleDisplayMode(.inline) + .onReceive(viewModel.events) { event in + if case let .error(eventError) = event { + error = eventError + } + } + .errorMessage($error) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + toggleAllSelection() + } + .buttonStyle(.toolbarPill) + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(L10n.cancel) { + cancelEditing() + } + .buttonStyle(.toolbarPill) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedSubtitles.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.updating), + isHidden: isEditing || !hasSubtitles + ) { + Section(L10n.add) { + Button(L10n.uploadFile, systemImage: "plus") { + router.route(to: .uploadSubtitle(viewModel: viewModel)) + } + + Button(L10n.search, systemImage: "magnifyingglass") { + router.route(to: .searchSubtitle(viewModel: viewModel)) + } + } + + if viewModel.externalSubtitles.isNotEmpty { + Section(L10n.manage) { + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + } + } + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + viewModel.send(.delete(selectedSubtitles)) + selectedSubtitles.removeAll() + isEditing = false + isPresentingDeleteConfirmation = false + } + } message: { + Text(L10n.deleteSelectedConfirmation) + } + } + + // MARK: - Content Views + + @ViewBuilder + private var contentView: some View { + List { + ListTitleSection( + L10n.subtitles, + description: L10n.manageSubtitlesDescription + ) + + if !hasSubtitles { + Button(L10n.uploadFile, systemImage: "plus") { + router.route(to: .uploadSubtitle(viewModel: viewModel)) + } + .foregroundStyle(.primary, .secondary) + + Button(L10n.search, systemImage: "magnifyingglass") { + router.route(to: .searchSubtitle(viewModel: viewModel)) + } + .foregroundStyle(.primary, .secondary) + } + + if viewModel.internalSubtitles.isNotEmpty { + Section { + DisclosureGroup(L10n.embedded) { + ForEach(viewModel.internalSubtitles, id: \.index) { subtitle in + SubtitleButton(subtitle) { + router.route(to: .mediaStreamInfo(mediaStream: subtitle)) + } + .environment(\.isEnabled, !isEditing) + } + } + } footer: { + Text(L10n.embeddedSubtitleFooter) + } + } + + if viewModel.externalSubtitles.isNotEmpty { + Section { + DisclosureGroup(L10n.external) { + ForEach(viewModel.externalSubtitles, id: \.index) { subtitle in + SubtitleButton(subtitle) { + if isEditing { + selectedSubtitles.toggle(value: subtitle) + } else { + router.route(to: .mediaStreamInfo(mediaStream: subtitle)) + } + } deleteAction: { + selectedSubtitles = [subtitle] + isPresentingDeleteConfirmation = true + } + .isSelected(selectedSubtitles.contains(subtitle)) + .isEditing(isEditing) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift new file mode 100644 index 00000000..c2962346 --- /dev/null +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -0,0 +1,45 @@ +// +// 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 + +struct ItemOverviewView: View { + + @Router + private var router + + let item: BaseItemDto + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 10) { + + if let firstTagline = item.taglines?.first { + Text(firstTagline) + .font(.title3) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + } + + if let itemOverview = item.overview { + Text(itemOverview) + .font(.body) + .multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .edgePadding() + } + .navigationTitle(item.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/ItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/CollectionItemContentView.swift new file mode 100644 index 00000000..ea4aab84 --- /dev/null +++ b/Swiftfin/Views/ItemView/CollectionItemContentView.swift @@ -0,0 +1,107 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import OrderedCollections +import SwiftUI + +// TODO: Show show name in episode subheader + +extension ItemView { + + struct CollectionItemContentView: View { + + typealias Element = OrderedDictionary.Elements.Element + + @Router + private var router + + @ObservedObject + var viewModel: CollectionItemViewModel + + private func episodeHStack(element: Element) -> some View { + VStack(alignment: .leading) { + + Text(L10n.episodes) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .edgePadding(.horizontal) + + CollectionHStack( + uniqueElements: element.value.elements, + id: \.unwrappedIDHashOrZero, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + } + } + + private func posterHStack(element: Element) -> some View { + PosterHStack( + title: element.key.pluralDisplayTitle, + type: element.key.preferredPosterDisplayType, + items: element.value.elements + ) { item, namespace in + router.route(to: .item(item: item), in: namespace) + } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: .library(viewModel: element.value)) + } + } + } + + var body: some View { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + + // MARK: - Items + + ForEach( + viewModel.sections.elements, + id: \.key + ) { element in + if element.key == .episode { + episodeHStack(element: element) + } else { + posterHStack(element: element) + } + } + + // MARK: Genres + + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { + ItemView.GenresHStack(genres: genres) + } + + // MARK: Studios + + if let studios = viewModel.item.studios, studios.isNotEmpty { + ItemView.StudiosHStack(studios: studios) + } + + // MARK: Similar + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift new file mode 100644 index 00000000..91831d9f --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift @@ -0,0 +1,148 @@ +// +// 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 CollectionHStack +import Defaults +import IdentifiedCollections +import JellyfinAPI +import SwiftUI + +// TODO: rename `AboutItemView` +// TODO: see what to do about bottom padding +// - don't like it adds more than the edge +// - just have this determine bottom padding +// instead of scrollviews? + +extension ItemView { + + struct AboutView: View { + + private enum AboutViewItem: Identifiable { + case image + case overview + case mediaSource(MediaSourceInfo) + case ratings + + var id: String? { + switch self { + case .image: + return "image" + case .overview: + return "overview" + case let .mediaSource(source): + return source.id + case .ratings: + return "ratings" + } + } + } + + @ObservedObject + var viewModel: ItemViewModel + + @State + private var contentSize: CGSize = .zero + + private var items: [AboutViewItem] { + var items: [AboutViewItem] = [ + .image, + .overview, + ] + + if let mediaSources = viewModel.item.mediaSources { + items.append(contentsOf: mediaSources.map { AboutViewItem.mediaSource($0) }) + } + + if viewModel.item.hasRatings { + items.append(.ratings) + } + + return items + } + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel + } + + // TODO: break out into a general solution for general use? + // use similar math from CollectionHStack + private var padImageWidth: CGFloat { + let portraitMinWidth: CGFloat = 140 + let contentWidth = contentSize.width + let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 + var columns = CGFloat(Int(usableWidth / portraitMinWidth)) + let preItemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) + let preTotalNegative = EdgeInsets.edgePadding * 2 + preItemSpacing + + if columns * portraitMinWidth + preTotalNegative > contentWidth { + columns -= 1 + } + + let itemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) + let totalNegative = EdgeInsets.edgePadding * 2 + itemSpacing + let itemWidth = (contentWidth - totalNegative) / columns + + return max(0, itemWidth) + } + + private var phoneImageWidth: CGFloat { + let contentWidth = contentSize.width + let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 + let itemSpacing = (EdgeInsets.edgePadding / 2) * 2 + let itemWidth = (usableWidth - itemSpacing) / 3 + + return max(0, itemWidth) + } + + private var cardSize: CGSize { + let height = UIDevice.isPad ? padImageWidth * 3 / 2 : phoneImageWidth * 3 / 2 + let width = height * 1.65 + + return CGSize(width: width, height: height) + } + + var body: some View { + VStack(alignment: .leading) { + L10n.about.text + .font(.title2) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) + .edgePadding(.horizontal) + + CollectionHStack( + uniqueElements: items, + variadicWidths: true + ) { item in + switch item { + case .image: + ImageCard(viewModel: viewModel) + .frame(width: UIDevice.isPad ? padImageWidth : phoneImageWidth) + case .overview: + OverviewCard(item: viewModel.item) + .frame(width: cardSize.width, height: cardSize.height) + case let .mediaSource(source): + MediaSourcesCard( + subtitle: (viewModel.item.mediaSources ?? []).count > 1 ? source.displayTitle : nil, + source: source + ) + .frame(width: cardSize.width, height: cardSize.height) + case .ratings: + RatingsCard(item: viewModel.item) + .frame(width: cardSize.width, height: cardSize.height) + } + } + .clipsToBounds(false) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollBehavior(.continuousLeadingEdge) + } + .trackingSize($contentSize) + .id(viewModel.item.hashValue) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift new file mode 100644 index 00000000..68902066 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift @@ -0,0 +1,64 @@ +// +// 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.AboutView { + + struct Card: View { + + private var action: () -> Void + private var content: Content + private let title: String + private let subtitle: String? + + init( + title: String, + subtitle: String? = nil, + action: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.action = action + self.content = content() + } + + var body: some View { + Button(action: action) { + ZStack(alignment: .leading) { + + Rectangle() + .fill(Color.systemFill) + .cornerRadius(ratio: 1 / 45, of: \.height) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + content + .frame(maxHeight: .infinity, alignment: .bottomLeading) + } + .padding() + } + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift new file mode 100644 index 00000000..8b5e13c0 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift @@ -0,0 +1,54 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView.AboutView { + + struct ImageCard: View { + + // MARK: - Environment & Observed Objects + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + // MARK: - Body + + var body: some View { + PosterButton( + item: viewModel.item, + type: .portrait, + action: action + ) { + EmptyView() + } + .posterOverlay(for: BaseItemDto.self) { _ in + EmptyView() + } + } + + // Switch case to allow other funcitonality if we need to expand this beyond episode > series + private func action(namespace: Namespace.ID) { + switch viewModel.item.type { + case .episode: + if let episodeViewModel = viewModel as? EpisodeItemViewModel, + let seriesItem = episodeViewModel.seriesItem + { + router.route(to: .item(item: seriesItem), in: namespace) + } + default: + break + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift new file mode 100644 index 00000000..7a715d06 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift @@ -0,0 +1,45 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView.AboutView { + + struct MediaSourcesCard: View { + + @Default(.accentColor) + private var accentColor + + @Router + private var router + + let subtitle: String? + let source: MediaSourceInfo + + var body: some View { + Card(title: L10n.media, subtitle: subtitle) { + router.route(to: .mediaSourceInfo(source: source)) + } content: { + if let mediaStreams = source.mediaStreams { + VStack(alignment: .leading) { + Text(mediaStreams.compactMap(\.displayTitle).prefix(4).joined(separator: "\n")) + .font(.footnote) + + if mediaStreams.count > 4 { + L10n.seeMore.text + .font(.footnote) + .foregroundColor(accentColor) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift new file mode 100644 index 00000000..8ae1aed7 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift @@ -0,0 +1,38 @@ +// +// 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.AboutView { + + struct OverviewCard: View { + + @Router + private var router + + let item: BaseItemDto + + var body: some View { + Card(title: item.displayTitle, subtitle: item.alternateTitle) { + router.route(to: .itemOverview(item: item)) + } content: { + if let overview = item.overview { + TruncatedText(overview) + .lineLimit(4) + .font(.footnote) + .allowsHitTesting(false) + } else { + L10n.noOverviewAvailable.text + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift new file mode 100644 index 00000000..d2e20bb6 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift @@ -0,0 +1,54 @@ +// +// 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.AboutView { + + struct RatingsCard: View { + + let item: BaseItemDto + + var body: some View { + Card(title: L10n.ratings, action: {}) { + HStack(alignment: .bottom, spacing: 20) { + if let criticRating = item.criticRating { + VStack { + Group { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.multicolor) + .foregroundStyle(.green, .red) + } else { + Image(.tomatoRotten) + .symbolRenderingMode(.monochrome) + .foregroundColor(.green) + } + } + .font(.largeTitle) + + Text("\(criticRating, specifier: "%.0f")") + } + } + + if let communityRating = item.communityRating { + VStack { + Image(systemName: "star.fill") + .symbolRenderingMode(.multicolor) + .foregroundStyle(.yellow) + .font(.largeTitle) + + Text("\(communityRating, specifier: "%.1f")") + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift new file mode 100644 index 00000000..aadf9e6e --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift @@ -0,0 +1,122 @@ +// +// 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 + + @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: 10) { + + if viewModel.item.canBePlayed { + + // MARK: - Toggle Played + + let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true + + Button(L10n.played, systemImage: "checkmark") { + viewModel.send(.toggleIsPlayed) + } + .buttonStyle(.tintedMaterial(tint: .jellyfinPurple, foregroundColor: .white)) + .isSelected(isCheckmarkSelected) + .frame(maxWidth: .infinity) + .if(!equalSpacing) { view in + view.aspectRatio(1, contentMode: .fit) + } + } + + // MARK: - Toggle Favorite + + let isHeartSelected = viewModel.item.userData?.isFavorite == true + + Button(L10n.favorite, systemImage: isHeartSelected ? "heart.fill" : "heart") { + viewModel.send(.toggleIsFavorite) + } + .buttonStyle(.tintedMaterial(tint: .red, foregroundColor: .white)) + .isSelected(isHeartSelected) + .frame(maxWidth: .infinity) + .if(!equalSpacing) { view in + view.aspectRatio(1, contentMode: .fit) + } + + // MARK: - Select a Version + + if let mediaSources = viewModel.playButtonItem?.mediaSources, + mediaSources.count > 1 + { + VersionMenu( + viewModel: viewModel, + mediaSources: mediaSources + ) + .menuStyle(.button) + .frame(maxWidth: .infinity) + .if(!equalSpacing) { view in + view.aspectRatio(1, contentMode: .fit) + } + } + + // MARK: - Watch a Trailer + + if hasTrailers { + TrailerMenu( + localTrailers: viewModel.localTrailers, + externalTrailers: viewModel.item.remoteTrailers ?? [] + ) + .menuStyle(.button) + .frame(maxWidth: .infinity) + .if(!equalSpacing) { view in + view.aspectRatio(1, contentMode: .fit) + } + } + } + .font(.title3) + .fontWeight(.semibold) + .buttonStyle(.material) + .labelStyle(.iconOnly) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift new file mode 100644 index 00000000..ff6b8b1a --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift @@ -0,0 +1,136 @@ +// +// 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 Logging +import SwiftUI + +extension ItemView { + + struct TrailerMenu: View { + + // MARK: - Stored Value + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + // MARK: - Observed & Envirnoment Objects + + @Router + private var router + + // MARK: - Error State + + @State + private var error: Error? + + let localTrailers: [BaseItemDto] + let externalTrailers: [MediaURL] + private let logger = Logger.swiftfin() + + 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 { + Button( + L10n.trailers, + systemImage: "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 { + Menu(L10n.trailers, systemImage: "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 mediaSource = trailer.mediaSources?.first { + router.route(to: .videoPlayer(item: trailer, mediaSource: mediaSource)) + } else { + logger.log(level: .error, "No media sources found") + error = JellyfinAPIError(L10n.unknownError) + } + } + + // MARK: - Play: External Trailer + + private func playExternalTrailer(_ trailer: MediaURL) { + if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) { success in + guard !success else { return } + + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } else { + error = JellyfinAPIError(L10n.unableToOpenTrailer) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift new file mode 100644 index 00000000..ef925548 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift @@ -0,0 +1,48 @@ +// +// 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 { + + // TODO: take in binding instead of view model + struct VersionMenu: View { + + @ObservedObject + var viewModel: ItemViewModel + + let mediaSources: [MediaSourceInfo] + + private var selectedMediaSourceBinding: Binding { + Binding( + get: { viewModel.selectedMediaSource }, + set: { newSource in + if let newSource { + viewModel.send(.selectMediaSource(newSource)) + } + } + ) + } + + // MARK: - Body + + var body: some View { + Menu(L10n.version, systemImage: "list.dash") { + Picker(L10n.version, selection: selectedMediaSourceBinding) { + ForEach(mediaSources, id: \.hashValue) { mediaSource in + Button { + Text(mediaSource.displayTitle) + } + .tag(mediaSource as MediaSourceInfo?) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift b/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift new file mode 100644 index 00000000..aa8f6930 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift @@ -0,0 +1,34 @@ +// +// 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 + +// TODO: make queue for parts + +extension ItemView { + + struct AdditionalPartsHStack: View { + + @Router + private var router + + let items: [BaseItemDto] + + var body: some View { + PosterHStack( + title: "Additional Parts", + type: .landscape, + items: items + ) { item, _ in + guard let mediaSource = item.mediaSources?.first else { return } + router.route(to: .videoPlayer(item: item, mediaSource: mediaSource)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift new file mode 100644 index 00000000..05ed6ca3 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift @@ -0,0 +1,153 @@ +// +// 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 AttributesHStack: View { + + @ObservedObject + private var viewModel: ItemViewModel + + private let alignment: HorizontalAlignment + private let attributes: [ItemViewAttribute] + private let flowDirection: FlowLayout.Direction + + init( + attributes: [ItemViewAttribute], + viewModel: ItemViewModel, + alignment: HorizontalAlignment = .center, + flowDirection: FlowLayout.Direction = .up + ) { + self.viewModel = viewModel + self.alignment = alignment + self.attributes = attributes + self.flowDirection = flowDirection + } + + var body: some View { + if attributes.isNotEmpty { + FlowLayout( + alignment: alignment, + direction: flowDirection + ) { + ForEach(attributes, id: \.self) { attribute in + switch attribute { + case .ratingCritics: CriticRating() + case .ratingCommunity: CommunityRating() + case .ratingOfficial: OfficialRating() + case .videoQuality: VideoQuality() + case .audioChannels: AudioChannels() + case .subtitles: Subtitles() + } + } + } + .foregroundStyle(Color(UIColor.darkGray)) + .lineLimit(1) + } + } + + @ViewBuilder + private func CriticRating() -> some View { + if let criticRating = viewModel.item.criticRating { + AttributeBadge( + style: .outline, + title: Text("\(criticRating, specifier: "%.0f")") + ) { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) + } + } + } + } + + @ViewBuilder + private func CommunityRating() -> some View { + if let communityRating = viewModel.item.communityRating { + AttributeBadge( + style: .outline, + title: Text("\(communityRating, specifier: "%.01f")"), + systemName: "star.fill" + ) + } + } + + @ViewBuilder + private func OfficialRating() -> some View { + if let officialRating = viewModel.item.officialRating { + AttributeBadge( + style: .outline, + title: officialRating + ) + } + } + + @ViewBuilder + private func VideoQuality() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + if mediaStreams.has4KVideo { + AttributeBadge( + style: .fill, + title: "4K" + ) + } else if mediaStreams.hasHDVideo { + AttributeBadge( + style: .fill, + title: "HD" + ) + } + if mediaStreams.hasDolbyVision { + AttributeBadge( + style: .fill, + title: "DV" + ) + } + if mediaStreams.hasHDRVideo { + AttributeBadge( + style: .fill, + title: "HDR" + ) + } + } + } + + @ViewBuilder + private func AudioChannels() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + if mediaStreams.has51AudioChannelLayout { + AttributeBadge( + style: .fill, + title: "5.1" + ) + } + if mediaStreams.has71AudioChannelLayout { + AttributeBadge( + style: .fill, + title: "7.1" + ) + } + } + } + + @ViewBuilder + private func Subtitles() -> some View { + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, + mediaStreams.hasSubtitles + { + AttributeBadge( + style: .outline, + title: "CC" + ) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift new file mode 100644 index 00000000..90ee755b --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift @@ -0,0 +1,40 @@ +// +// 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 CastAndCrewHStack: View { + + @Router + private var router + + let people: [BaseItemPerson] + + var body: some View { + PosterHStack( + title: L10n.castAndCrew, + type: .portrait, + items: people.filter { person in + person.type?.isSupported ?? false + } + ) { person, namespace in + router.route(to: .item(item: .init(person: person)), in: namespace) + } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: .castAndCrew(people: people, itemID: nil)) + router.route(to: .castAndCrew(people: people, itemID: nil)) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift new file mode 100644 index 00000000..5c0d37e4 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift @@ -0,0 +1,59 @@ +// +// 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 + +struct DownloadTaskButton: View { + + @ObservedObject + private var downloadManager: DownloadManager + @ObservedObject + private var downloadTask: DownloadTask + + private var onSelect: (DownloadTask) -> Void + + var body: some View { + Button { + onSelect(downloadTask) + } label: { + switch downloadTask.state { + case .cancelled: + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + case .complete: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .downloading: + EmptyView() +// CircularProgressView(progress: progress) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + case .ready: + Image(systemName: "arrow.down.circle") + } + } + } +} + +extension DownloadTaskButton { + + init(item: BaseItemDto) { + let downloadManager = Container.shared.downloadManager() + + self.downloadTask = downloadManager.task(for: item) ?? .init(item: item) + self.onSelect = { _ in } + self.downloadManager = downloadManager + } + + func onSelect(_ action: @escaping (DownloadTask) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift new file mode 100644 index 00000000..ef5f4663 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift @@ -0,0 +1,31 @@ +// +// 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 SeriesEpisodeSelector { + + struct EmptyCard: View { + + var body: some View { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + + SeriesEpisodeSelector.EpisodeContent( + header: L10n.noResults, + subHeader: .emptyDash, + content: L10n.noEpisodesAvailable, + action: {} + ) + .disabled(true) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift new file mode 100644 index 00000000..363cf6f7 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -0,0 +1,88 @@ +// +// 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 SeriesEpisodeSelector { + + struct EpisodeCard: View { + + @Namespace + private var namespace + + @Router + private var router + + let episode: BaseItemDto + + @ViewBuilder + private var overlayView: some View { + if let progressLabel = episode.progressLabel { + LandscapePosterProgressBar( + title: progressLabel, + progress: (episode.userData?.playedPercentage ?? 0) / 100 + ) + } else if episode.userData?.isPlayed ?? false { + ZStack(alignment: .bottomTrailing) { + Color.clear + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 30, height: 30, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black) + .padding() + } + } + } + + private var episodeContent: String { + if episode.isUnaired { + episode.airDateLabel ?? L10n.noOverviewAvailable + } else { + episode.overview ?? L10n.noOverviewAvailable + } + } + + var body: some View { + VStack(alignment: .leading) { + Button { + router.route( + to: .videoPlayer( + item: episode, + queue: EpisodeMediaPlayerQueue(episode: episode) + ) + ) + } label: { + ImageView(episode.imageSource(.primary, maxWidth: 250)) + .failure { + SystemImageContentView(systemName: episode.systemImage) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + overlayView + } + .contentShape(.contextMenuPreview, Rectangle()) + .backport + .matchedTransitionSource(id: "item", in: namespace) + .posterStyle(.landscape) + .posterShadow() + } + + SeriesEpisodeSelector.EpisodeContent( + header: episode.displayTitle, + subHeader: episode.episodeLocator ?? .emptyDash, + content: episodeContent + ) { + router.route(to: .item(item: episode), in: namespace) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift new file mode 100644 index 00000000..698fe4ed --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift @@ -0,0 +1,69 @@ +// +// 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 SeriesEpisodeSelector { + + struct EpisodeContent: View { + + @Default(.accentColor) + private var accentColor + + let header: String + let subHeader: String + let content: String + let action: () -> Void + + @ViewBuilder + private var subHeaderView: some View { + Text(subHeader) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + } + + @ViewBuilder + private var headerView: some View { + Text(header) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + .multilineTextAlignment(.leading) + .padding(.bottom, 1) + } + + @ViewBuilder + private var contentView: some View { + Text(content) + .font(.caption) + .fontWeight(.light) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3, reservesSpace: true) + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading) { + subHeaderView + + headerView + + contentView + + L10n.seeMore.text + .font(.caption) + .fontWeight(.light) + .foregroundStyle(accentColor) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift new file mode 100644 index 00000000..3b0076de --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -0,0 +1,123 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import SwiftUI + +// TODO: The content/loading/error states are implemented as different CollectionHStacks because it was just easy. +// A theoretically better implementation would be a single CollectionHStack with cards that represent the state instead. +extension SeriesEpisodeSelector { + + struct EpisodeHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + @State + private var didScrollToPlayButtonItem = false + + @StateObject + private var proxy = CollectionHStackProxy() + + let playButtonItem: BaseItemDto? + + private func contentView(viewModel: SeasonItemViewModel) -> some View { + CollectionHStack( + uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + } + .clipsToBounds(false) + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .proxy(proxy) + .onFirstAppear { + guard !didScrollToPlayButtonItem else { return } + didScrollToPlayButtonItem = true + + // good enough? + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let playButtonItem else { return } + proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) + } + } + } + + var body: some View { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + EmptyHStack() + } else { + contentView(viewModel: viewModel) + } + case let .error(error): + ErrorHStack(viewModel: viewModel, error: error) + case .initial, .refreshing: + LoadingHStack() + } + } + } + + struct EmptyHStack: View { + + var body: some View { + CollectionHStack( + count: 1, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.EmptyCard() + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } + + // TODO: better refresh design + struct ErrorHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + let error: JellyfinAPIError + + var body: some View { + CollectionHStack( + count: 1, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.ErrorCard(error: error) { + viewModel.send(.refresh) + } + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } + + struct LoadingHStack: View { + + var body: some View { + CollectionHStack( + count: Int.random(in: 2 ..< 5), + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.LoadingCard() + } + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollDisabled(true) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift new file mode 100644 index 00000000..51470bca --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift @@ -0,0 +1,39 @@ +// +// 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 SeriesEpisodeSelector { + + struct ErrorCard: View { + + let error: JellyfinAPIError + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 40)) + } + + SeriesEpisodeSelector.EpisodeContent( + header: L10n.error, + subHeader: .emptyDash, + content: error.localizedDescription, + action: action + ) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift new file mode 100644 index 00000000..5bc6d3e5 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift @@ -0,0 +1,31 @@ +// +// 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 SeriesEpisodeSelector { + + struct LoadingCard: View { + + var body: some View { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + + SeriesEpisodeSelector.EpisodeContent( + header: String.random(count: 10 ..< 20), + subHeader: String.random(count: 7 ..< 12), + content: String.random(count: 20 ..< 80), + action: {} + ) + .redacted(reason: .placeholder) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift new file mode 100644 index 00000000..1aa45b67 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -0,0 +1,91 @@ +// +// 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 + +struct SeriesEpisodeSelector: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + @State + private var didSelectPlayButtonSeason = false + @State + private var selection: SeasonItemViewModel.ID? + + private var selectionViewModel: SeasonItemViewModel? { + viewModel.seasons.first(where: { $0.id == selection }) + } + + @ViewBuilder + private var seasonSelectorMenu: some View { + if let seasonDisplayName = selectionViewModel?.season.displayTitle, + viewModel.seasons.count <= 1 + { + Text(seasonDisplayName) + .font(.title2) + .fontWeight(.semibold) + } else { + Menu { + ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in + Button { + selection = seasonViewModel.id + } label: { + if seasonViewModel.id == selection { + Label(seasonViewModel.season.displayTitle, systemImage: "checkmark") + } else { + Text(seasonViewModel.season.displayTitle) + } + } + } + } label: { + Label( + selectionViewModel?.season.displayTitle ?? .emptyDash, + systemImage: "chevron.down" + ) + .labelStyle(.episodeSelector) + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + seasonSelectorMenu + .edgePadding(.horizontal) + + Group { + if let selectionViewModel { + EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) + } else { + LoadingHStack() + } + } + .transition(.opacity.animation(.linear(duration: 0.1))) + } + .onReceive(viewModel.playButtonItem.publisher) { newValue in + + guard !didSelectPlayButtonSeason else { return } + didSelectPlayButtonSeason = true + + if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) { + selection = playButtonSeason.id + } else { + selection = viewModel.seasons.first?.id + } + } + .onChange(of: selection) { _ in + guard let selectionViewModel else { return } + + if selectionViewModel.state == .initial { + selectionViewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/GenresHStack.swift b/Swiftfin/Views/ItemView/Components/GenresHStack.swift new file mode 100644 index 00000000..9504b0a5 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/GenresHStack.swift @@ -0,0 +1,35 @@ +// +// 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 GenresHStack: View { + + @Router + private var router + + let genres: [ItemGenre] + + var body: some View { + PillHStack( + title: L10n.genres, + items: genres + ).onSelect { genre in + let viewModel = ItemLibraryViewModel( + title: genre.displayTitle, + id: genre.value, + filters: .init(genres: [genre]) + ) + router.route(to: .library(viewModel: viewModel)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift b/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift new file mode 100644 index 00000000..246ddd33 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift @@ -0,0 +1,93 @@ +// +// 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 + +// TODO: given height or height ratio options + +// The fading values just "feel right" and is the same for iOS and iPadOS. +// Adjust if necessary or if a more concrete design comes along. + +extension ItemView { + + struct OffsetScrollView: View { + + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var size: CGSize = .zero + @State + private var safeAreaInsets: EdgeInsets = .zero + + private let header: Header + private let overlay: Overlay + private let content: Content + private let heightRatio: CGFloat + + init( + heightRatio: CGFloat = 0, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder overlay: @escaping () -> Overlay, + @ViewBuilder content: @escaping () -> Content + ) { + self.header = header() + self.overlay = overlay() + self.content = content() + self.heightRatio = clamp(heightRatio, min: 0, max: 1) + } + + private var headerOpacity: CGFloat { + let headerHeight = headerHeight + let start = headerHeight - safeAreaInsets.top - 90 + let end = headerHeight - safeAreaInsets.top - 40 + let diff = end - start + let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) + return opacity + } + + private var headerHeight: CGFloat { + (size.height + safeAreaInsets.vertical) * heightRatio + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + AlternateLayoutView { + Color.clear + .frame(height: headerHeight, alignment: .bottom) + } content: { + overlay + .frame(height: headerHeight, alignment: .bottom) + } + .overlay { + Color.systemBackground + .opacity(headerOpacity) + } + + content + } + } + .edgesIgnoringSafeArea(.top) + .trackingSize($size, $safeAreaInsets) + .scrollViewOffset($scrollViewOffset) + .navigationBarOffset( + $scrollViewOffset, + start: headerHeight - safeAreaInsets.top - 45, + end: headerHeight - safeAreaInsets.top - 5 + ) + .backgroundParallaxHeader( + $scrollViewOffset, + height: headerHeight, + multiplier: 0.3 + ) { + header + .frame(height: headerHeight) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/OverviewView.swift b/Swiftfin/Views/ItemView/Components/OverviewView.swift new file mode 100644 index 00000000..f8c26def --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/OverviewView.swift @@ -0,0 +1,90 @@ +// +// 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 + +// TODO: have items provide labeled attributes +// TODO: don't layout `VStack` if no data + +extension ItemView { + + struct OverviewView: View { + + @Router + private var router + + let item: BaseItemDto + private var overviewLineLimit: Int? + private var taglineLineLimit: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let firstTagline = item.taglines?.first { + Text(firstTagline) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .lineLimit(taglineLineLimit) + } + + if let itemOverview = item.overview { + TruncatedText(itemOverview) + .onSeeMore { + router.route(to: .itemOverview(item: item)) + } + .seeMoreType(.view) + .lineLimit(overviewLineLimit) + } + + if let birthday = item.birthday?.formatted(date: .long, time: .omitted) { + LabeledContent( + L10n.born, + value: birthday + ) + } + + if let deathday = item.deathday?.formatted(date: .long, time: .omitted) { + LabeledContent( + L10n.died, + value: deathday + ) + } + + if let birthplace = item.birthplace { + LabeledContent( + L10n.birthplace, + value: birthplace + ) + } + } + .font(.footnote) + .labeledContentStyle(.itemAttribute) + } + } +} + +extension ItemView.OverviewView { + + init(item: BaseItemDto) { + self.init( + item: item, + overviewLineLimit: nil, + taglineLineLimit: nil + ) + } + + func overviewLineLimit(_ limit: Int) -> Self { + copy(modifying: \.overviewLineLimit, with: limit) + } + + func taglineLineLimit(_ limit: Int) -> Self { + copy(modifying: \.taglineLineLimit, with: limit) + } +} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift new file mode 100644 index 00000000..49e4e30b --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/PlayButton.swift @@ -0,0 +1,134 @@ +// +// 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 JellyfinAPI +import Logging +import SwiftUI + +extension ItemView { + + struct PlayButton: View { + + @Default(.accentColor) + private var accentColor + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + private let logger = Logger.swiftfin() + + // MARK: - Validation + + private var isEnabled: Bool { + viewModel.selectedMediaSource != nil + } + + // MARK: - Title + + private var title: String { + /// Use the Season/Episode label for the Series ItemView + if let seriesViewModel = viewModel as? SeriesItemViewModel, + let seasonEpisodeLabel = seriesViewModel.playButtonItem?.seasonEpisodeLabel + { + return seasonEpisodeLabel + + /// Use a Play/Resume label for single Media Source items that are not Series + } else if let playButtonLabel = viewModel.playButtonItem?.playButtonLabel { + return playButtonLabel + + /// Fallback to a generic `Play` label + } else { + return L10n.play + } + } + + // MARK: - Media Source + + private var source: String? { + guard let sourceLabel = viewModel.selectedMediaSource?.displayTitle, + viewModel.item.mediaSources?.count ?? 0 > 1 + else { + return nil + } + + return sourceLabel + } + + // MARK: - Body + + var body: some View { + Button { + play() + } label: { + HStack { + Label(title, systemImage: "play.fill") + .font(.callout) + .fontWeight(.semibold) + + if let source { + Marquee(source, speed: 40, delay: 3, fade: 5) + .font(.caption) + .fontWeight(.medium) + .frame(maxWidth: 175) + } + } + .padding(.horizontal, 5) + } + .buttonStyle(.tintedMaterial(tint: accentColor, foregroundColor: accentColor.overlayColor)) + .isSelected(true) + .contextMenu { + if viewModel.playButtonItem?.userData?.playbackPositionTicks != 0 { + Button(L10n.playFromBeginning, systemImage: "gobackward") { + play(fromBeginning: true) + } + } + } + .disabled(!isEnabled) + } + + // MARK: - Play Content + + private func play(fromBeginning: Bool = false) { + guard let playButtonItem = viewModel.playButtonItem, + let selectedMediaSource = viewModel.selectedMediaSource + else { + logger.error("Play selected with no item or media source") + return + } + + let queue: (any MediaPlayerQueue)? = { + if playButtonItem.type == .episode { + return EpisodeMediaPlayerQueue(episode: playButtonItem) + } + return nil + }() + + let provider = MediaPlayerItemProvider(item: playButtonItem) { item in + try await MediaPlayerItem.build( + for: item, + mediaSource: selectedMediaSource + ) { + if fromBeginning { + $0.userData?.playbackPositionTicks = 0 + } + } + } + + router.route( + to: .videoPlayer( + provider: provider, + queue: queue + ) + ) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift new file mode 100644 index 00000000..020cd0b0 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift @@ -0,0 +1,47 @@ +// +// 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 JellyfinAPI +import OrderedCollections +import SwiftUI + +extension ItemView { + + struct SimilarItemsHStack: View { + + @Default(.Customization.similarPosterType) + private var similarPosterType + + @Router + private var router + + @StateObject + private var viewModel: PagingLibraryViewModel + + init(items: [BaseItemDto]) { + self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) + } + + var body: some View { + PosterHStack( + title: L10n.recommended, + type: similarPosterType, + items: viewModel.elements + ) { item, namespace in + router.route(to: .item(item: item), in: namespace) + } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: .library(viewModel: viewModel)) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift new file mode 100644 index 00000000..d3b834fa --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift @@ -0,0 +1,33 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension ItemView { + + struct SpecialFeaturesHStack: View { + + @Router + private var router + + let items: [BaseItemDto] + + var body: some View { + PosterHStack( + title: L10n.specialFeatures, + type: .landscape, + items: items + ) { item, _ in + guard let mediaSource = item.mediaSources?.first else { return } + router.route(to: .videoPlayer(item: item, mediaSource: mediaSource)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift new file mode 100644 index 00000000..398aa428 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift @@ -0,0 +1,31 @@ +// +// 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 StudiosHStack: View { + + @Router + private var router + + let studios: [NameGuidPair] + + var body: some View { + PillHStack( + title: L10n.studios, + items: studios + ).onSelect { studio in + let viewModel = ItemLibraryViewModel(parent: studio) + router.route(to: .library(viewModel: viewModel)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift new file mode 100644 index 00000000..04e3820a --- /dev/null +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -0,0 +1,198 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ItemView: View { + + protocol ScrollContainerView: View { + + associatedtype Content: View + + init(viewModel: ItemViewModel, content: @escaping () -> Content) + } + + @Default(.Customization.itemViewType) + private var itemViewType + + @Router + private var router + + @StateObject + private var viewModel: ItemViewModel + @StateObject + private var deleteViewModel: DeleteItemViewModel + + @State + private var isPresentingConfirmationDialog = false + @State + private var isPresentingEventAlert = false + @State + private var error: JellyfinAPIError? + + // MARK: - Can Delete Item + + private var canDelete: Bool { + viewModel.userSession.user.permissions.items.canDelete(item: viewModel.item) + } + + // MARK: - Can Edit Item + + private var canEdit: Bool { + viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) + // TODO: Enable when Subtitle / Lyric Editing is added + // || viewModel.userSession.user.permissions.items.canManageLyrics(item: viewModel.item) + // || viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) + } + + // MARK: - Deletion or Editing is Enabled + + private var enableMenu: Bool { + canEdit || canDelete + } + + private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { + switch item.type { + case .boxSet, .person, .musicArtist: + return CollectionItemViewModel(item: item) + case .episode: + return EpisodeItemViewModel(item: item) + case .movie: + return MovieItemViewModel(item: item) + case .musicVideo, .video: + return ItemViewModel(item: item) + case .series: + return SeriesItemViewModel(item: item) + default: + assertionFailure("Unsupported item") + return ItemViewModel(item: item) + } + } + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) + self._deleteViewModel = StateObject(wrappedValue: DeleteItemViewModel(item: item)) + } + + @ViewBuilder + private var scrollContentView: some View { + switch viewModel.item.type { + case .boxSet, .person, .musicArtist: + CollectionItemContentView(viewModel: viewModel as! CollectionItemViewModel) + case .episode, .musicVideo, .video: + SimpleItemContentView(viewModel: viewModel) + case .movie: + MovieItemContentView(viewModel: viewModel as! MovieItemViewModel) + case .series: + SeriesItemContentView(viewModel: viewModel as! SeriesItemViewModel) + default: + Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) + } + } + + // TODO: break out into pad vs phone views based on item type + private func scrollContainerView( + viewModel: ItemViewModel, + content: @escaping () -> Content + ) -> any ScrollContainerView { + + if UIDevice.isPad { + return iPadOSCinematicScrollView(viewModel: viewModel, content: content) + } + + switch viewModel.item.type { + case .movie, .series: + switch itemViewType { + case .compactPoster: + return CompactPosterScrollView(viewModel: viewModel, content: content) + case .compactLogo: + return CompactLogoScrollView(viewModel: viewModel, content: content) + case .cinematic: + return CinematicScrollView(viewModel: viewModel, content: content) + } + case .person, .musicArtist: + return CompactPosterScrollView(viewModel: viewModel, content: content) + default: + return SimpleScrollView(viewModel: viewModel, content: content) + } + } + + @ViewBuilder + private var innerBody: some View { + scrollContainerView(viewModel: viewModel) { + scrollContentView + } + .eraseToAnyView() + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + innerBody + .navigationTitle(viewModel.item.displayTitle) + case let .error(error): + ErrorView(error: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + viewModel.send(.refresh) + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refresh), + isHidden: !enableMenu + ) { + if canEdit { + Button(L10n.edit, systemImage: "pencil") { + router.route(to: .itemEditor(viewModel: viewModel)) + } + } + + if canDelete { + Section { + Button(L10n.delete, systemImage: "trash", role: .destructive) { + isPresentingConfirmationDialog = true + } + } + } + } + .confirmationDialog( + L10n.deleteItemConfirmationMessage, + isPresented: $isPresentingConfirmationDialog, + titleVisibility: .visible + ) { + Button(L10n.confirm, role: .destructive) { + deleteViewModel.send(.delete) + } + Button(L10n.cancel, role: .cancel) {} + } + .onReceive(deleteViewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + isPresentingEventAlert = true + case .deleted: + router.dismiss() + } + } + .alert( + L10n.error, + isPresented: $isPresentingEventAlert, + presenting: error + ) { _ in + } message: { error in + Text(error.localizedDescription) + } + } +} diff --git a/Swiftfin/Views/ItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/MovieItemContentView.swift new file mode 100644 index 00000000..27c21d08 --- /dev/null +++ b/Swiftfin/Views/ItemView/MovieItemContentView.swift @@ -0,0 +1,68 @@ +// +// 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 MovieItemContentView: View { + + @ObservedObject + var viewModel: MovieItemViewModel + + var body: some View { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + + // MARK: Genres + + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { + ItemView.GenresHStack(genres: genres) + } + + // MARK: Studios + + if let studios = viewModel.item.studios, studios.isNotEmpty { + ItemView.StudiosHStack(studios: studios) + } + + // MARK: - Parts + + // TODO: Implement after part queue made +// if viewModel.additionalParts.isNotEmpty { +// AdditionalPartsHStack(items: viewModel.additionalParts) +// } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people, + castAndCrew.isNotEmpty + { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + // MARK: Special Features + + if viewModel.specialFeatures.isNotEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + } + + // MARK: Similar + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift new file mode 100644 index 00000000..6b7908a0 --- /dev/null +++ b/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -0,0 +1,163 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct CinematicScrollView: ScrollContainerView { + + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var usePrimaryImage + + @Router + private var router + + @ObservedObject + private var viewModel: ItemViewModel + + private let content: Content + + init( + viewModel: ItemViewModel, + content: @escaping () -> Content + ) { + self.content = content() + self.viewModel = viewModel + } + + private var imageType: ImageType { + usePrimaryImage ? .primary : .backdrop + } + + @ViewBuilder + private var headerView: some View { + + let bottomColor = viewModel.item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill + + GeometryReader { proxy in + if proxy.size.height.isZero { EmptyView() } + else { + ImageView(viewModel.item.imageSource( + imageType, + maxWidth: usePrimaryImage ? proxy.size.width : 0, + maxHeight: usePrimaryImage ? 0 : proxy.size.height * 0.6 + )) + .aspectRatio(usePrimaryImage ? (2 / 3) : 1.77, contentMode: .fill) + .frame(width: proxy.size.width, height: proxy.size.height * 0.6) + .bottomEdgeGradient(bottomColor: bottomColor) + } + } + } + + var body: some View { + OffsetScrollView(heightRatio: 0.75) { + headerView + } overlay: { + OverlayView(viewModel: viewModel) + .edgePadding(.horizontal) + .edgePadding(.bottom) + .frame(maxWidth: .infinity) + .background { + BlurView(style: .systemThinMaterialDark) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.3, opacity: 1) + (location: 1, opacity: 1) + } + } + } content: { + content + .padding(.top, 10) + .edgePadding(.bottom) + } + } + } +} + +extension ItemView.CinematicScrollView { + + struct OverlayView: View { + + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var usePrimaryImage + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @Router + private var router + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .center, spacing: 10) { + if !usePrimaryImage { + ImageView(viewModel.item.imageURL(.logo, maxHeight: 100)) + .placeholder { _ in + EmptyView() + } + .failure { + MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) + .font(.largeTitle.weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .aspectRatio(contentMode: .fit) + .frame(height: 100, alignment: .bottom) + } + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + .padding(.horizontal) + + Group { + if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + } + + ItemView.ActionButtonHStack(viewModel: viewModel) + .foregroundStyle(.white) + .frame(height: 50) + } + .frame(maxWidth: 300) + } + .frame(maxWidth: .infinity) + + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(3) + .taglineLineLimit(2) + .foregroundColor(.white) + + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel, + alignment: .leading + ) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift new file mode 100644 index 00000000..aa1bcaf7 --- /dev/null +++ b/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift @@ -0,0 +1,143 @@ +// +// 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 CompactLogoScrollView: ScrollContainerView { + + @Router + private var router + + @ObservedObject + private var viewModel: ItemViewModel + + private let content: Content + + init( + viewModel: ItemViewModel, + content: @escaping () -> Content + ) { + self.content = content() + self.viewModel = viewModel + } + + @ViewBuilder + private var headerView: some View { + + let bottomColor = viewModel.item.blurHash(for: .backdrop)?.averageLinearColor ?? Color.secondarySystemFill + + GeometryReader { proxy in + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1320)) + .aspectRatio(1.77, contentMode: .fill) + .frame(width: proxy.size.width, height: proxy.size.height * 0.70, alignment: .top) + .bottomEdgeGradient(bottomColor: bottomColor) + } + } + + var body: some View { + OffsetScrollView(heightRatio: 0.5) { + headerView + } overlay: { + OverlayView(viewModel: viewModel) + .edgePadding(.horizontal) + .edgePadding(.bottom) + .frame(maxWidth: .infinity) + .background { + BlurView(style: .systemThinMaterialDark) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.3, opacity: 1) + } + } + } content: { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .taglineLineLimit(2) + .edgePadding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + + content + } + .edgePadding(.vertical) + } + } + } +} + +extension ItemView.CompactLogoScrollView { + + struct OverlayView: View { + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .center, spacing: 10) { + ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) + .placeholder { _ in + EmptyView() + } + .failure { + MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 70) + .font(.largeTitle.weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .aspectRatio(contentMode: .fit) + .frame(height: 70, alignment: .bottom) + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + .padding(.horizontal) + + Group { + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel + ) + + if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + } + + ItemView.ActionButtonHStack(viewModel: viewModel) + .foregroundStyle(.white) + .frame(height: 50) + } + .frame(maxWidth: 300) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift new file mode 100644 index 00000000..087080e8 --- /dev/null +++ b/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift @@ -0,0 +1,198 @@ +// +// 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 CompactPosterScrollView: ScrollContainerView { + + @Router + private var router + + @ObservedObject + private var viewModel: ItemViewModel + + private let content: Content + + init( + viewModel: ItemViewModel, + @ViewBuilder content: @escaping () -> Content + ) { + self.content = content() + self.viewModel = viewModel + } + + private func withHeaderImageItem( + @ViewBuilder content: @escaping (ImageSource, Color) -> some View + ) -> some View { + + let item: BaseItemDto + + if viewModel.item.type == .person || viewModel.item.type == .musicArtist, + let typeViewModel = viewModel as? CollectionItemViewModel, + let randomItem = typeViewModel.randomItem() + { + item = randomItem + } else { + item = viewModel.item + } + + let imageType: ImageType = item.type == .episode ? .primary : .backdrop + let bottomColor = item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill + let imageSource = item.imageSource(imageType, maxWidth: 1320) + + return content(imageSource, bottomColor) + .id(imageSource.url?.hashValue) + .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) + } + + @ViewBuilder + private var headerView: some View { + GeometryReader { proxy in + withHeaderImageItem { imageSource, bottomColor in + ImageView(imageSource) + .aspectRatio(1.77, contentMode: .fill) + .frame(width: proxy.size.width, height: proxy.size.height * 0.78, alignment: .top) + .bottomEdgeGradient(bottomColor: bottomColor) + } + } + } + + var body: some View { + OffsetScrollView(heightRatio: 0.45) { + headerView + } overlay: { + OverlayView(viewModel: viewModel) + .edgePadding(.horizontal) + .edgePadding(.bottom) + .frame(maxWidth: .infinity) + .background { + BlurView(style: .systemThinMaterialDark) + .maskLinearGradient { + (location: 0.2, opacity: 0) + (location: 0.3, opacity: 0.5) + (location: 0.55, opacity: 1) + } + } + } content: { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .taglineLineLimit(2) + .edgePadding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + + content + } + .edgePadding(.vertical) + } + } + } +} + +// TODO: have action buttons part of the right shelf view +// - possible on leading edge instead + +extension ItemView.CompactPosterScrollView { + + struct OverlayView: View { + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @Router + private var router + + @ObservedObject + var viewModel: ItemViewModel + + @ViewBuilder + private var rightShelfView: some View { + VStack(alignment: .leading) { + + Text(viewModel.item.displayTitle) + .font(.title2) + .lineLimit(2) + .fontWeight(.semibold) + .foregroundColor(.white) + + DotHStack { + if viewModel.item.type == .person { + if let birthday = viewModel.item.birthday { + Text( + birthday, + format: .age.death(viewModel.item.deathday) + ) + } + } else { + if viewModel.item.isUnaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + } + } else { + if let productionYear = viewModel.item.premiereDateYear { + Text(String(productionYear)) + } + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) + } + } + } + .lineLimit(1) + .font(.subheadline.weight(.medium)) + .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel, + alignment: .leading + ) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .bottom, spacing: 12) { + + PosterImage( + item: viewModel.item, + type: .portrait, + contentMode: .fit + ) + .environment(\.isOverComplexContent, true) + .frame(width: 130) + .accessibilityIgnoresInvertColors() + + rightShelfView + .padding(.bottom) + } + + HStack(alignment: .center) { + + if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(width: 130) + } + + Spacer() + + ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false) + .foregroundStyle(.white) + } + .frame(height: 45) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift new file mode 100644 index 00000000..e6dc53ba --- /dev/null +++ b/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift @@ -0,0 +1,146 @@ +// +// 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 BlurHashKit +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct SimpleScrollView: ScrollContainerView { + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @Router + private var router + + @ObservedObject + private var viewModel: ItemViewModel + + private let content: Content + + init( + viewModel: ItemViewModel, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.viewModel = viewModel + } + + @ViewBuilder + private var shelfView: some View { + VStack(alignment: .center, spacing: 10) { + if let parentTitle = viewModel.item.parentTitle { + Text(parentTitle) + .font(.headline) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + .foregroundColor(.secondary) + } + + Text(viewModel.item.displayTitle) + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + + DotHStack { + if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel { + Text(seasonEpisodeLabel) + } + + if let productionYear = viewModel.item.premiereDateYear { + Text(productionYear) + } + + if let runtime = viewModel.item.runTimeLabel { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + + Group { + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel, + alignment: .center + ) + + if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + } + + ItemView.ActionButtonHStack(viewModel: viewModel) + .frame(height: 50) + } + .frame(maxWidth: 300) + } + } + + // TODO: remove and just use `PosterImage` with landscape + // after poster environment implemented + private var imageType: ImageType { + switch viewModel.item.type { + case .episode, .musicVideo, .video: + .primary + default: + .backdrop + } + } + + @ViewBuilder + private var header: some View { + VStack(alignment: .center) { + ZStack { + Rectangle() + .fill(.complexSecondary) + + ImageView(viewModel.item.imageSource(imageType, maxWidth: 600)) + .failure { + SystemImageContentView(systemName: viewModel.item.systemImage) + } + } + .frame(maxHeight: 300) + .posterStyle(.landscape) + .posterShadow() + .padding(.horizontal) + + shelfView + } + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 10) { + + header + + // MARK: Overview + + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .padding(.horizontal) + + RowDivider() + + // MARK: Genres + + content + .edgePadding(.bottom) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift new file mode 100644 index 00000000..22a49195 --- /dev/null +++ b/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift @@ -0,0 +1,198 @@ +// +// 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 iPadOSCinematicScrollView: ScrollContainerView { + + @ObservedObject + private var viewModel: ItemViewModel + + @State + private var globalSize: CGSize = .zero + + private let content: Content + + init( + viewModel: ItemViewModel, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.viewModel = viewModel + } + + private var imageType: ImageType { + switch viewModel.item.type { + case .episode, .musicVideo, .video: + .primary + default: + .backdrop + } + } + + private func withHeaderImageItem( + @ViewBuilder content: @escaping (ImageSource, Color) -> some View + ) -> some View { + + let item: BaseItemDto + + if viewModel.item.type == .person || viewModel.item.type == .musicArtist, + let typeViewModel = viewModel as? CollectionItemViewModel, + let randomItem = typeViewModel.randomItem() + { + item = randomItem + } else { + item = viewModel.item + } + + let bottomColor = item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill + let imageSource = item.imageSource(imageType, maxWidth: 1920) + + return content(imageSource, bottomColor) + .id(imageSource.url?.hashValue) + .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) + } + + @ViewBuilder + private var headerView: some View { + withHeaderImageItem { imageSource, bottomColor in + ImageView(imageSource) + .aspectRatio(1.77, contentMode: .fill) + .bottomEdgeGradient(bottomColor: bottomColor) + } + } + + var body: some View { + OffsetScrollView( + heightRatio: globalSize.isLandscape ? 0.75 : 0.5 + ) { + headerView + } overlay: { + OverlayView(viewModel: viewModel) + .edgePadding() + .frame(maxWidth: .infinity) + .background { + BlurView(style: .systemThinMaterialDark) + .maskLinearGradient { + (location: 0.4, opacity: 0) + (location: 0.8, opacity: 1) + } + } + } content: { + content + .padding(.top, 10) + .edgePadding(.bottom) + } + .trackingSize($globalSize) + } + } +} + +extension ItemView.iPadOSCinematicScrollView { + + struct OverlayView: View { + + @StoredValue(.User.itemViewAttributes) + private var attributes + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + GeometryReader { geometry in + HStack(alignment: .bottom) { + + VStack(alignment: .leading, spacing: 20) { + + ImageView(viewModel.item.imageSource( + .logo, + maxHeight: 130 + )) + .placeholder { _ in + EmptyView() + } + .failure { + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundStyle(.white) + } + .aspectRatio(contentMode: .fit) + .frame(maxWidth: geometry.size.width * 0.4, maxHeight: 130, alignment: .bottomLeading) + + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(3) + .taglineLineLimit(2) + .foregroundStyle(.white) + + if viewModel.item.type != .person { + FlowLayout( + alignment: .leading, + direction: .down, + spacing: 30, + minRowLength: 1 + ) { + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { + Text(runtime) + } + } + .font(.footnote) + .foregroundStyle(Color(UIColor.lightGray)) + .fixedSize(horizontal: true, vertical: false) + + ItemView.AttributesHStack( + attributes: attributes, + viewModel: viewModel, + alignment: .leading + ) + } + } + } + .padding(.trailing, geometry.size.width * 0.05) + + Spacer() + + VStack(spacing: 10) { + if viewModel.item.type == .person || viewModel.item.type == .musicArtist { + ImageView(viewModel.item.imageSource(.primary, maxWidth: 200)) + .failure { + SystemImageContentView(systemName: viewModel.item.systemImage) + } + .posterStyle(.portrait, contentMode: .fit) + .frame(width: 200) + .accessibilityIgnoresInvertColors() + } else if viewModel.item.presentPlayButton { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + } + + ItemView.ActionButtonHStack(viewModel: viewModel) + .foregroundStyle(.white) + .frame(height: 50) + } + .frame(width: 250) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/SeriesItemContentView.swift new file mode 100644 index 00000000..44ae3ea1 --- /dev/null +++ b/Swiftfin/Views/ItemView/SeriesItemContentView.swift @@ -0,0 +1,67 @@ +// +// 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 SeriesItemContentView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + + // MARK: Episodes + + if viewModel.seasons.isNotEmpty { + SeriesEpisodeSelector(viewModel: viewModel) + } + + // MARK: Genres + + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { + ItemView.GenresHStack(genres: genres) + } + + // MARK: Studios + + if let studios = viewModel.item.studios, studios.isNotEmpty { + ItemView.StudiosHStack(studios: studios) + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people, + castAndCrew.isNotEmpty + { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + // MARK: Special Features + + if viewModel.specialFeatures.isNotEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + } + + // MARK: Similar + + if viewModel.similarItems.isNotEmpty { + ItemView.SimilarItemsHStack(items: viewModel.similarItems) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/SimpleItemContentView.swift b/Swiftfin/Views/ItemView/SimpleItemContentView.swift new file mode 100644 index 00000000..c1347d76 --- /dev/null +++ b/Swiftfin/Views/ItemView/SimpleItemContentView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct SimpleItemContentView: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + SeparatorVStack(alignment: .leading) { + RowDivider() + .padding(.vertical, 10) + } content: { + + // MARK: Genres + + if let genres = viewModel.item.itemGenres, genres.isNotEmpty { + ItemView.GenresHStack(genres: genres) + } + + // MARK: Studios + + if let studios = viewModel.item.studios, studios.isNotEmpty { + ItemView.StudiosHStack(studios: studios) + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people, + castAndCrew.isNotEmpty + { + ItemView.CastAndCrewHStack(people: castAndCrew) + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/MediaSourceInfoView.swift b/Swiftfin/Views/MediaSourceInfoView.swift new file mode 100644 index 00000000..62133af9 --- /dev/null +++ b/Swiftfin/Views/MediaSourceInfoView.swift @@ -0,0 +1,64 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct MediaSourceInfoView: View { + + @Router + private var router + + let source: MediaSourceInfo + + var body: some View { + Form { + if let videoStreams = source.videoStreams, + videoStreams.isNotEmpty + { + Section(L10n.video) { + ForEach(videoStreams, id: \.self) { stream in + ChevronButton(stream.displayTitle ?? .emptyDash) { + router.route(to: .mediaStreamInfo(mediaStream: stream)) + } + } + } + } + + if let audioStreams = source.audioStreams, + audioStreams.isNotEmpty + { + Section(L10n.audio) { + ForEach(audioStreams, id: \.self) { stream in + ChevronButton(stream.displayTitle ?? .emptyDash) { + router.route(to: .mediaStreamInfo(mediaStream: stream)) + } + } + } + } + + if let subtitleStreams = source.subtitleStreams, + subtitleStreams.isNotEmpty + { + Section(L10n.subtitle) { + ForEach(subtitleStreams, id: \.self) { stream in + ChevronButton(stream.displayTitle ?? .emptyDash) { + router.route(to: .mediaStreamInfo(mediaStream: stream)) + } + } + } + } + } + .navigationTitle(source.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/MediaStreamInfoView.swift b/Swiftfin/Views/MediaStreamInfoView.swift new file mode 100644 index 00000000..033241c3 --- /dev/null +++ b/Swiftfin/Views/MediaStreamInfoView.swift @@ -0,0 +1,51 @@ +// +// 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 + +struct MediaStreamInfoView: View { + + let mediaStream: MediaStream + + var body: some View { + Form { + Section { + ForEach(mediaStream.metadataProperties, id: \.label) { property in + LabeledContent( + property.label, + value: property.value + ) + } + } + + if mediaStream.colorProperties.isNotEmpty { + Section(L10n.color) { + ForEach(mediaStream.colorProperties, id: \.label) { property in + LabeledContent( + property.label, + value: property.value + ) + } + } + } + + if mediaStream.deliveryProperties.isNotEmpty { + Section(L10n.delivery) { + ForEach(mediaStream.deliveryProperties, id: \.label) { property in + LabeledContent( + property.label, + value: property.value + ) + } + } + } + } + .navigationTitle(mediaStream.displayTitle ?? .emptyDash) + } +} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift new file mode 100644 index 00000000..8e8f5ba0 --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -0,0 +1,121 @@ +// +// 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 + +private let landscapeMaxWidth: CGFloat = 110 +private let portraitMaxWidth: CGFloat = 60 + +extension PagingLibraryView { + + struct LibraryRow: View { + + @Namespace + private var namespace + + private let item: Element + private var action: (Namespace.ID) -> Void + private let posterType: PosterDisplayType + + init( + item: Element, + posterType: PosterDisplayType, + action: @escaping (Namespace.ID) -> Void + ) { + self.item = item + self.action = action + self.posterType = posterType + } + + @ViewBuilder + private func itemAccessoryView(item: BaseItemDto) -> some View { + DotHStack { + if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { + Text(seasonEpisodeLocator) + } else if let premiereYear = item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = item.runTimeLabel { + Text(runtime) + } + + if let officialRating = item.officialRating { + Text(officialRating) + } + } + } + + @ViewBuilder + private func personAccessoryView(person: BaseItemPerson) -> some View { + if let subtitle = person.subtitle { + Text(subtitle) + } + } + + @ViewBuilder + private var accessoryView: some View { + switch item { + case let element as BaseItemDto: + itemAccessoryView(item: element) + case let element as BaseItemPerson: + personAccessoryView(person: element) + default: + AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") + } + } + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(item.displayTitle) + .font(posterType == .landscape ? .subheadline : .callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + accessoryView + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + } + + Spacer() + } + } + + @ViewBuilder + private var rowLeading: some View { + PosterImage( + item: item, + type: posterType, + contentMode: .fill + ) + .posterShadow() + .frame(width: posterType == .landscape ? landscapeMaxWidth : portraitMaxWidth) + .padding(.vertical, 8) + } + + // MARK: body + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + rowContent + } + .onSelect { + action(namespace) + } + .backport + .matchedTransitionSource(id: "item", in: namespace) + } + } +} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift new file mode 100644 index 00000000..002777ac --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift @@ -0,0 +1,97 @@ +// +// 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 + +// TODO: rename `LibraryDisplayTypeToggle`/Section +// - change to 2 Menu's in a section with subtitle +// like on `SelectUserView`? + +extension PagingLibraryView { + + struct LibraryViewTypeToggle: View { + + @Binding + private var listColumnCount: Int + @Binding + private var posterType: PosterDisplayType + @Binding + private var viewType: LibraryDisplayType + + init( + posterType: Binding, + viewType: Binding, + listColumnCount: Binding + ) { + self._listColumnCount = listColumnCount + self._posterType = posterType + self._viewType = viewType + } + + var body: some View { + Menu { + + Section(L10n.posters) { + Button { + posterType = .landscape + } label: { + if posterType == .landscape { + Label(L10n.landscape, systemImage: "checkmark") + } else { + Label(L10n.landscape, systemImage: "rectangle") + } + } + + Button { + posterType = .portrait + } label: { + if posterType == .portrait { + Label(L10n.portrait, systemImage: "checkmark") + } else { + Label(L10n.portrait, systemImage: "rectangle.portrait") + } + } + } + + Section(L10n.layout) { + Button { + viewType = .grid + } label: { + if viewType == .grid { + Label(L10n.grid, systemImage: "checkmark") + } else { + Label(L10n.grid, systemImage: "square.grid.2x2.fill") + } + } + + Button { + viewType = .list + } label: { + if viewType == .list { + Label(L10n.list, systemImage: "checkmark") + } else { + Label(L10n.list, systemImage: "square.fill.text.grid.1x2") + } + } + } + + if viewType == .list, UIDevice.isPad { + Stepper(L10n.columnsWithCount(listColumnCount), value: $listColumnCount, in: 1 ... 3) + } + } label: { + switch viewType { + case .grid: + Label(L10n.layout, systemImage: "square.grid.2x2.fill") + case .list: + Label(L10n.layout, systemImage: "square.fill.text.grid.1x2") + } + } + } + } +} diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift new file mode 100644 index 00000000..e9bae24e --- /dev/null +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -0,0 +1,474 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import Nuke +import SwiftUI + +// TODO: need to think about better design for views that may not support current library display type +// - ex: channels/albums when in portrait/landscape +// - just have the supported view embedded in a container view? +// TODO: could bottom (defaults + stored) `onChange` copies be cleaned up? +// - more could be cleaned up if there was a "switcher" property wrapper that takes two +// sources and a switch and holds the current expected value +// - or if Defaults values were moved to StoredValues and each key would return/respond to +// what values they should have +// TODO: when there are no filters sometimes navigation bar will be clear until popped back to + +/* + Note: Currently, it is a conscious decision to not have grid posters have subtitle content. + This is due to episodes, which have their `S_E_` subtitles, and these can be alongside + other items that don't have a subtitle which requires the entire library to implement + subtitle content but that doesn't look appealing. Until a solution arrives grid posters + will not have subtitle content. + There should be a solution since there are contexts where subtitles are desirable and/or + we can have subtitle content for other items. + + Note: For `rememberLayout` and `rememberSort`, there are quirks for observing changes while a + library is open and the setting has been changed. For simplicity, do not enforce observing + changes and doing proper updates since there is complexity with what "actual" settings + should be applied. + */ + +struct PagingLibraryView: View { + + @Default(.Customization.Library.enabledDrawerFilters) + private var enabledDrawerFilters + @Default(.Customization.Library.rememberLayout) + private var rememberLayout + + @Default(.Customization.Library.displayType) + private var defaultDisplayType: LibraryDisplayType + @Default(.Customization.Library.listColumnCount) + private var defaultListColumnCount: Int + @Default(.Customization.Library.posterType) + private var defaultPosterType: PosterDisplayType + + @Default(.Customization.Library.letterPickerEnabled) + private var letterPickerEnabled + @Default(.Customization.Library.letterPickerOrientation) + private var letterPickerOrientation + + @Namespace + private var namespace + + @Router + private var router + + @State + private var layout: CollectionVGridLayout + @State + private var safeArea: EdgeInsets = .zero + + @StoredValue + private var displayType: LibraryDisplayType + @StoredValue + private var listColumnCount: Int + @StoredValue + private var posterType: PosterDisplayType + + @StateObject + private var collectionVGridProxy: CollectionVGridProxy = .init() + @StateObject + private var viewModel: PagingLibraryViewModel + + // MARK: init + + init(viewModel: PagingLibraryViewModel) { + + // have to set these properties manually to get proper initial layout + + self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) + self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) + self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) + + self._viewModel = StateObject(wrappedValue: viewModel) + + let defaultDisplayType = Defaults[.Customization.Library.displayType] + let defaultListColumnCount = Defaults[.Customization.Library.listColumnCount] + let defaultPosterType = Defaults[.Customization.Library.posterType] + + let displayType = StoredValues[.User.libraryDisplayType(parentID: viewModel.parent?.id)] + let listColumnCount = StoredValues[.User.libraryListColumnCount(parentID: viewModel.parent?.id)] + let posterType = StoredValues[.User.libraryPosterType(parentID: viewModel.parent?.id)] + + let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType + let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? listColumnCount : defaultListColumnCount + let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType + + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: initialPosterType, + viewType: initialDisplayType + ) + } else { + layout = Self.padLayout( + posterType: initialPosterType, + viewType: initialDisplayType, + listColumnCount: initialListColumnCount + ) + } + } + + // MARK: onSelect + + private func onSelect(_ element: Element, in namespace: Namespace.ID) { + switch element { + case let element as BaseItemDto: + select(item: element, in: namespace) + case let element as BaseItemPerson: + select(item: BaseItemDto(person: element), in: namespace) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + + private func select(item: BaseItemDto, in namespace: Namespace.ID) { + switch item.type { + case .collectionFolder, .folder: + let viewModel = ItemLibraryViewModel(parent: item, filters: .default) + router.route(to: .library(viewModel: viewModel), in: namespace) + default: + router.route(to: .item(item: item), in: namespace) + } + } + + // MARK: layout + + // TODO: rename old "viewType" paramter to "displayType" and sort + + private static func padLayout( + posterType: PosterDisplayType, + viewType: LibraryDisplayType, + listColumnCount: Int + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + .minWidth(200) + case (.portrait, .grid), (.square, .grid): + .minWidth(150) + case (_, .list): + .columns(listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + private static func phoneLayout( + posterType: PosterDisplayType, + viewType: LibraryDisplayType + ) -> CollectionVGridLayout { + switch (posterType, viewType) { + case (.landscape, .grid): + .columns(2) + case (.portrait, .grid): + .columns(3) + case (.square, .grid): + .columns(3) + case (_, .list): + .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + // MARK: item view + + // Note: if parent is a folders then other items will have labels, + // so an empty content view is necessary + + @ViewBuilder + private func gridItemView(item: Element, posterType: PosterDisplayType) -> some View { + PosterButton( + item: item, + type: posterType + ) { namespace in + onSelect(item, in: namespace) + } label: { + if item.showTitle { + PosterButton.TitleContentView(title: item.displayTitle) + .lineLimit(1, reservesSpace: true) + } else if viewModel.parent?.libraryType == .folder { + PosterButton.TitleContentView(title: item.displayTitle) + .lineLimit(1, reservesSpace: true) + .hidden() + } + } + } + + @ViewBuilder + private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { + LibraryRow( + item: item, + posterType: posterType + ) { namespace in + onSelect(item, in: namespace) + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + @ViewBuilder + private var elementsView: some View { + CollectionVGrid( + uniqueElements: viewModel.elements, + id: \.unwrappedIDHashOrZero, + layout: layout + ) { item in + let displayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType + let posterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType + + switch displayType { + case .grid: + gridItemView(item: item, posterType: posterType) + case .list: + listItemView(item: item, posterType: posterType) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + .proxy(collectionVGridProxy) + .scrollIndicators(.hidden) + } + + @ViewBuilder + private var innerContent: some View { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + elementsView + } + case .initial, .refreshing: + DelayedProgressView() + default: + AssertionFailureView("Expected view for unexpected state") + } + } + + @ViewBuilder + private var contentView: some View { + if letterPickerEnabled, let filterViewModel = viewModel.filterViewModel { + ZStack(alignment: letterPickerOrientation.alignment) { + innerContent + .padding(letterPickerOrientation.edge, LetterPickerBar.size + 10) + .frame(maxWidth: .infinity) + + LetterPickerBar(viewModel: filterViewModel) + .padding(.top, safeArea.top) + .padding(.bottom, safeArea.bottom) + .padding(letterPickerOrientation.edge, 10) + } + } else { + innerContent + } + } + + // MARK: body + + // TODO: becoming too large for typechecker during development, should break up somehow + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .content, .initial, .refreshing: + contentView + case let .error(error): + errorView(with: error) + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .onSizeChanged { _, safeArea in + self.safeArea = safeArea + } + .navigationTitle(viewModel.parent?.displayTitle ?? "") + .navigationBarTitleDisplayMode(.inline) + .ifLet(viewModel.filterViewModel) { view, filterViewModel in + view.navigationBarFilterDrawer( + viewModel: filterViewModel, + types: enabledDrawerFilters + ) { + router.route(to: .filter(type: $0.type, viewModel: $0.viewModel)) + } + } + .onChange(of: defaultDisplayType) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: defaultPosterType, + viewType: newValue + ) + } else { + layout = Self.padLayout( + posterType: defaultPosterType, + viewType: newValue, + listColumnCount: defaultListColumnCount + ) + } + } + .onChange(of: defaultListColumnCount) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + + if UIDevice.isPad { + layout = Self.padLayout( + posterType: defaultPosterType, + viewType: defaultDisplayType, + listColumnCount: newValue + ) + } + } + .onChange(of: defaultPosterType) { newValue in + guard !Defaults[.Customization.Library.rememberLayout] else { return } + + if UIDevice.isPhone { + if defaultDisplayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.phoneLayout( + posterType: newValue, + viewType: defaultDisplayType + ) + } + } else { + if defaultDisplayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.padLayout( + posterType: newValue, + viewType: defaultDisplayType, + listColumnCount: defaultListColumnCount + ) + } + } + } + .onChange(of: displayType) { newValue in + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: posterType, + viewType: newValue + ) + } else { + layout = Self.padLayout( + posterType: posterType, + viewType: newValue, + listColumnCount: listColumnCount + ) + } + } + .onChange(of: listColumnCount) { newValue in + if UIDevice.isPad { + layout = Self.padLayout( + posterType: posterType, + viewType: displayType, + listColumnCount: newValue + ) + } + } + .onChange(of: posterType) { newValue in + if UIDevice.isPhone { + if displayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.phoneLayout( + posterType: newValue, + viewType: displayType + ) + } + } else { + if displayType == .list { + collectionVGridProxy.layout() + } else { + layout = Self.padLayout( + posterType: newValue, + viewType: displayType, + listColumnCount: listColumnCount + ) + } + } + } + .onChange(of: rememberLayout) { newValue in + let newDisplayType = newValue ? displayType : defaultDisplayType + let newListColumnCount = newValue ? listColumnCount : defaultListColumnCount + let newPosterType = newValue ? posterType : defaultPosterType + + if UIDevice.isPhone { + layout = Self.phoneLayout( + posterType: newPosterType, + viewType: newDisplayType + ) + } else { + layout = Self.padLayout( + posterType: newPosterType, + viewType: newDisplayType, + listColumnCount: newListColumnCount + ) + } + } + .onChange(of: viewModel.filterViewModel?.currentFilters) { newValue in + guard let newValue, let id = viewModel.parent?.id else { return } + + if Defaults[.Customization.Library.rememberSort] { + let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] + .mutating(\.sortBy, with: newValue.sortBy) + .mutating(\.sortOrder, with: newValue.sortOrder) + + StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .gotRandomItem(item): + switch item { + case let item as BaseItemDto: + select(item: item, in: namespace) + case let item as BaseItemPerson: + select(item: BaseItemDto(person: item), in: namespace) + default: + assertionFailure("Used an unexpected type within a `PagingLibaryView`?") + } + } + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.gettingNextPage) + ) { + if Defaults[.Customization.Library.rememberLayout] { + LibraryViewTypeToggle( + posterType: $posterType, + viewType: $displayType, + listColumnCount: $listColumnCount + ) + } else { + LibraryViewTypeToggle( + posterType: $defaultPosterType, + viewType: $defaultDisplayType, + listColumnCount: $defaultListColumnCount + ) + } + + Button(L10n.random, systemImage: "dice.fill") { + viewModel.send(.getRandomItem) + } + .disabled(viewModel.elements.isEmpty) + } + } +} diff --git a/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift b/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift new file mode 100644 index 00000000..611245c3 --- /dev/null +++ b/Swiftfin/Views/PhotoPickerView/Components/PhotoCropView.swift @@ -0,0 +1,161 @@ +// +// 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 Mantis +import SwiftUI + +struct PhotoCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @StateObject + private var proxy: _PhotoCropView.Proxy = .init() + + // MARK: - Image Variable + + let isSaving: Bool + let image: UIImage + let cropShape: Mantis.CropShapeType + let presetRatio: Mantis.PresetFixedRatioType + let onSave: (UIImage) -> Void + let onCancel: () -> Void + + // MARK: - Body + + var body: some View { + _PhotoCropView( + initialImage: image, + cropShape: cropShape, + presetRatio: presetRatio, + proxy: proxy, + onImageCropped: onSave + ) + .topBarTrailing { + + Button(L10n.rotate, systemImage: "rotate.right") { + proxy.rotate() + } + + if isSaving { + Button(L10n.cancel, action: onCancel) + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + proxy.crop() + } + .buttonStyle(.toolbarPill) + } + } + .toolbar { + ToolbarItem(placement: .principal) { + if isSaving { + ProgressView() + } else { + Button(L10n.reset) { + proxy.reset() + } + .foregroundStyle(.yellow) + .disabled(isSaving) + } + } + } + .ignoresSafeArea() + .background { + Color.black + } + } +} + +// MARK: - Photo Crop View + +private struct _PhotoCropView: UIViewControllerRepresentable { + + class Proxy: ObservableObject { + + weak var cropViewController: CropViewController? + + func crop() { + cropViewController?.crop() + } + + func reset() { + cropViewController?.didSelectReset() + } + + func rotate() { + cropViewController?.didSelectClockwiseRotate() + } + } + + let initialImage: UIImage + let cropShape: Mantis.CropShapeType + let presetRatio: Mantis.PresetFixedRatioType + let proxy: Proxy + let onImageCropped: (UIImage) -> Void + + func makeUIViewController(context: Context) -> some UIViewController { + var config = Mantis.Config() + + config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9) + config.cropViewConfig.cropShapeType = cropShape + config.presetFixedRatioType = presetRatio + config.showAttachedCropToolbar = false + + let cropViewController = Mantis.cropViewController( + image: initialImage, + config: config + ) + + cropViewController.delegate = context.coordinator + context.coordinator.onImageCropped = onImageCropped + + proxy.cropViewController = cropViewController + + return cropViewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: CropViewControllerDelegate { + + var onImageCropped: ((UIImage) -> Void)? + + func cropViewControllerDidCrop( + _ cropViewController: CropViewController, + cropped: UIImage, + transformation: Transformation, + cropInfo: CropInfo + ) { + onImageCropped?(cropped) + } + + func cropViewControllerDidCancel( + _ cropViewController: CropViewController, + original: UIImage + ) {} + + func cropViewControllerDidFailToCrop( + _ cropViewController: CropViewController, + original: UIImage + ) {} + + func cropViewControllerDidBeginResize( + _ cropViewController: CropViewController + ) {} + + func cropViewControllerDidEndResize( + _ cropViewController: Mantis.CropViewController, + original: UIImage, + cropInfo: Mantis.CropInfo + ) {} + } +} diff --git a/Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift b/Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift new file mode 100644 index 00000000..a95e0108 --- /dev/null +++ b/Swiftfin/Views/PhotoPickerView/PhotoPickerView.swift @@ -0,0 +1,86 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import PhotosUI +import SwiftUI + +// TODO: polish: find way to deselect image on appear +// - from popping from cropping +// TODO: polish: when image is picked, instead of loading it here +// which takes ~1-2s, show some kind of loading indicator +// on this view or push to another view that will go to crop + +struct PhotoPickerView: UIViewControllerRepresentable { + + // MARK: - Photo Picker Actions + + var onSelect: (UIImage) -> Void + var onCancel: () -> Void + + // MARK: - Initializer + + init(onSelect: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { + self.onSelect = onSelect + self.onCancel = onCancel + } + + // MARK: - UIView Controller + + func makeUIViewController(context: Context) -> PHPickerViewController { + + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + + configuration.filter = .all(of: [.images, .not(.livePhotos)]) + configuration.preferredAssetRepresentationMode = .current + configuration.selection = .default + configuration.selectionLimit = 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + + context.coordinator.onSelect = onSelect + context.coordinator.onCancel = onCancel + + return picker + } + + // MARK: - Update UIView Controller + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + // MARK: - Make Coordinator + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // MARK: - Coordinator + + class Coordinator: PHPickerViewControllerDelegate { + + var onSelect: ((UIImage) -> Void)? + var onCancel: (() -> Void)? + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + + guard let image = results.first else { + onCancel?() + return + } + + let itemProvider = image.itemProvider + + guard itemProvider.canLoadObject(ofClass: UIImage.self) else { return } + + itemProvider.loadObject(ofClass: UIImage.self) { image, _ in + guard let image = image as? UIImage else { return } + self.onSelect?(image) + } + } + } +} diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift new file mode 100644 index 00000000..d6e325d5 --- /dev/null +++ b/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift @@ -0,0 +1,51 @@ +// +// 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 ProgramsView { + + struct ProgramButtonContent: View { + + let program: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + + Text(program.channelName ?? .emptyDash) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + Text(program.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + HStack(spacing: 2) { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + Text("-") + + if let endDate = program.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift new file mode 100644 index 00000000..a1adf0ee --- /dev/null +++ b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -0,0 +1,39 @@ +// +// 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 + +// TODO: item-type dependent views may be more appropriate near/on +// the `PosterButton` object instead of on these larger views +extension ProgramsView { + + struct ProgramProgressOverlay: View { + + @State + private var programProgress: Double = 0.0 + + let program: BaseItemDto + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + WrappedView { + if let startDate = program.startDate, startDate < Date.now { + LandscapePosterProgressBar( + progress: program.programProgress ?? 0 + ) + } + } + .onReceive(timer) { newValue in + if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { + programProgress = newValue.timeIntervalSince(startDate) / duration + } + } + } + } +} diff --git a/Swiftfin/Views/ProgramsView/ProgramsView.swift b/Swiftfin/Views/ProgramsView/ProgramsView.swift new file mode 100644 index 00000000..d0a061eb --- /dev/null +++ b/Swiftfin/Views/ProgramsView/ProgramsView.swift @@ -0,0 +1,144 @@ +// +// 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 + +// TODO: background refresh for programs with timer? +// TODO: find other another way to handle channels/other views? + +// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program + +struct ProgramsView: View { + + @Router + private var router + + @StateObject + private var programsViewModel = ProgramsViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + programsViewModel.send(.refresh) + } + } + + @ViewBuilder + private var liveTVSectionScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + liveTVSectionPill( + title: L10n.channels, + systemImage: "play.square.stack" + ) { + router.route(to: .channels) + } + } + .edgePadding(.horizontal) + } + } + + // TODO: probably make own pill view + // - see if could merge with item view pills + @ViewBuilder + private func liveTVSectionPill(title: String, systemImage: String, onSelect: @escaping () -> Void) -> some View { + Button { + onSelect() + } label: { + Label(title, systemImage: systemImage) + .font(.callout.weight(.semibold)) + .foregroundColor(.primary) + .padding(8) + .background { + Color.systemFill + .cornerRadius(10) + } + } + } + + @ViewBuilder + private var contentView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + + liveTVSectionScrollView + + if programsViewModel.hasNoResults { + // TODO: probably change to "No Programs" + L10n.noResults.text + } + + if programsViewModel.recommended.isNotEmpty { + programsSection(title: L10n.onNow, keyPath: \.recommended) + } + + if programsViewModel.series.isNotEmpty { + programsSection(title: L10n.series, keyPath: \.series) + } + + if programsViewModel.movies.isNotEmpty { + programsSection(title: L10n.movies, keyPath: \.movies) + } + + if programsViewModel.kids.isNotEmpty { + programsSection(title: L10n.kids, keyPath: \.kids) + } + + if programsViewModel.sports.isNotEmpty { + programsSection(title: L10n.sports, keyPath: \.sports) + } + + if programsViewModel.news.isNotEmpty { + programsSection(title: L10n.news, keyPath: \.news) + } + } + } + } + + @ViewBuilder + private func programsSection( + title: String, + keyPath: KeyPath + ) -> some View { + PosterHStack( + title: title, + type: .landscape, + items: programsViewModel[keyPath: keyPath] + ) { _, _ in + // router.route( + // to: .liveVideoPlayer(manager: LiveVideoPlayerManager(program: item)) + // ) + } label: { + ProgramButtonContent(program: $0) + } + .posterOverlay(for: BaseItemDto.self) { + ProgramProgressOverlay(program: $0) + } + } + + var body: some View { + WrappedView { + switch programsViewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .navigationTitle(L10n.liveTV) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + if programsViewModel.state == .initial { + programsViewModel.send(.refresh) + } + } + } +} diff --git a/Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift b/Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift new file mode 100644 index 00000000..7aa0ca33 --- /dev/null +++ b/Swiftfin/Views/ResetUserPasswordView/ResetUserPasswordView.swift @@ -0,0 +1,194 @@ +// +// 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 Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct ResetUserPasswordView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Focus Fields + + private enum Field: Hashable { + case currentPassword + case newPassword + case confirmNewPassword + } + + @FocusState + private var focusedField: Field? + + // MARK: - State & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel: ResetUserPasswordViewModel + + // MARK: - Password Variables + + @State + private var currentPassword: String = "" + @State + private var newPassword: String = "" + @State + private var confirmNewPassword: String = "" + + private let requiresCurrentPassword: Bool + + // MARK: - Dialog States + + @State + private var isPresentingSuccess: Bool = false + + // MARK: - Error State + + @State + private var error: Error? = nil + + // MARK: - Initializer + + init(userID: String, requiresCurrentPassword: Bool) { + self._viewModel = StateObject(wrappedValue: ResetUserPasswordViewModel(userID: userID)) + self.requiresCurrentPassword = requiresCurrentPassword + } + + // MARK: - Body + + var body: some View { + List { + if requiresCurrentPassword { + Section(L10n.currentPassword) { + SecureField( + L10n.currentPassword, + text: $currentPassword, + maskToggle: .enabled + ) + .onSubmit { + focusedField = .newPassword + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedField, equals: .currentPassword) + .disabled(viewModel.state == .resetting) + } + } + + Section(L10n.newPassword) { + SecureField( + L10n.newPassword, + text: $newPassword, + maskToggle: .enabled + ) + .onSubmit { + focusedField = .confirmNewPassword + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedField, equals: .newPassword) + .disabled(viewModel.state == .resetting) + } + + Section { + SecureField( + L10n.confirmNewPassword, + text: $confirmNewPassword, + maskToggle: .enabled + ) + .onSubmit { + viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) + } + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($focusedField, equals: .confirmNewPassword) + .disabled(viewModel.state == .resetting) + } header: { + Text(L10n.confirmNewPassword) + } footer: { + if newPassword != confirmNewPassword { + Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + Section { + if viewModel.state == .resetting { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + + if requiresCurrentPassword { + focusedField = .currentPassword + } else { + focusedField = .newPassword + } + } + .foregroundStyle(.red, .red.opacity(0.2)) + } else { + ListRowButton(L10n.save) { + focusedField = nil + viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) + } + .disabled(newPassword != confirmNewPassword || viewModel.state == .resetting) + .foregroundStyle(accentColor.overlayColor, accentColor) + .opacity(newPassword != confirmNewPassword ? 0.5 : 1) + } + } footer: { + Text(L10n.passwordChangeWarning) + } + } + .interactiveDismissDisabled(viewModel.state == .resetting) + .navigationBarBackButtonHidden(viewModel.state == .resetting) + .navigationTitle(L10n.password) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + .onFirstAppear { + if requiresCurrentPassword { + focusedField = .currentPassword + } else { + focusedField = .newPassword + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .success: + UIDevice.feedback(.success) + isPresentingSuccess = true + } + } + .topBarTrailing { + if viewModel.state == .resetting { + ProgressView() + } + } + .alert( + L10n.success, + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) { + router.dismiss() + } + } message: { + Text(L10n.passwordChangedMessage) + } + .errorMessage($error) { + focusedField = .newPassword + } + } +} diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift new file mode 100644 index 00000000..e3800651 --- /dev/null +++ b/Swiftfin/Views/SearchView.swift @@ -0,0 +1,241 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: have a `SearchLibraryViewModel` that allows paging on searched items? +// TODO: implement search view result type between `PosterHStack` +// and `ListHStack` (3 row list columns)? (iOS only) +// TODO: have programs only pull recommended/current? +// - have progress overlay +struct SearchView: View { + + @Default(.Customization.Search.enabledDrawerFilters) + private var enabledDrawerFilters + @Default(.Customization.searchPosterType) + private var searchPosterType + + @FocusState + private var isSearchFocused: Bool + + @Router + private var router + + @State + private var searchQuery = "" + + @TabItemSelected + private var tabItemSelected + + @StateObject + private var viewModel = SearchViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.search(query: searchQuery) + } + } + + @ViewBuilder + private var suggestionsView: some View { + VStack(spacing: 20) { + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle + } + } + } + } + + @ViewBuilder + private var resultsView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if let movies = viewModel.items[.movie], movies.isNotEmpty { + itemsSection( + title: L10n.movies, + type: .movie, + items: movies, + posterType: searchPosterType + ) + } + + if let series = viewModel.items[.series], series.isNotEmpty { + itemsSection( + title: L10n.tvShows, + type: .series, + items: series, + posterType: searchPosterType + ) + } + + if let collections = viewModel.items[.boxSet], collections.isNotEmpty { + itemsSection( + title: L10n.collections, + type: .boxSet, + items: collections, + posterType: searchPosterType + ) + } + + if let episodes = viewModel.items[.episode], episodes.isNotEmpty { + itemsSection( + title: L10n.episodes, + type: .episode, + items: episodes, + posterType: searchPosterType + ) + } + + if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty { + itemsSection( + title: L10n.musicVideos, + type: .musicVideo, + items: musicVideos, + posterType: .landscape + ) + } + + if let videos = viewModel.items[.video], videos.isNotEmpty { + itemsSection( + title: L10n.videos, + type: .video, + items: videos, + posterType: .landscape + ) + } + + if let programs = viewModel.items[.program], programs.isNotEmpty { + itemsSection( + title: L10n.programs, + type: .program, + items: programs, + posterType: .landscape + ) + } + + if let channels = viewModel.items[.tvChannel], channels.isNotEmpty { + itemsSection( + title: L10n.channels, + type: .tvChannel, + items: channels, + posterType: .square + ) + } + + if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty { + itemsSection( + title: L10n.artists, + type: .musicArtist, + items: musicArtists, + posterType: .portrait + ) + } + + if let people = viewModel.items[.person], people.isNotEmpty { + itemsSection( + title: L10n.people, + type: .person, + items: people, + posterType: .portrait + ) + } + } + .edgePadding(.vertical) + } + } + + private func select(_ item: BaseItemDto, in namespace: Namespace.ID) { + switch item.type { + case .program, .tvChannel: + let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession) + router.route(to: .videoPlayer(provider: provider)) + default: + router.route(to: .item(item: item), in: namespace) + } + } + + @ViewBuilder + private func itemsSection( + title: String, + type: BaseItemKind, + items: [BaseItemDto], + posterType: PosterDisplayType + ) -> some View { + PosterHStack( + title: title, + type: posterType, + items: items, + action: select + ) + .trailing { + SeeAllButton() + .onSelect { + let viewModel = PagingLibraryViewModel( + title: title, + id: "search-\(type.hashValue)", + items + ) + router.route(to: .library(viewModel: viewModel)) + } + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + if viewModel.hasNoResults { + if searchQuery.isEmpty { + suggestionsView + } else { + Text(L10n.noResults) + } + } else { + resultsView + } + case .searching: + ProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.items) + .animation(.linear(duration: 0.2), value: viewModel.state) + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle(L10n.search) + .navigationBarTitleDisplayMode(.inline) + .navigationBarFilterDrawer( + viewModel: viewModel.filterViewModel, + types: enabledDrawerFilters + ) { + router.route(to: .filter(type: $0.type, viewModel: $0.viewModel)) + } + .onFirstAppear { + viewModel.getSuggestions() + } + .onChange(of: searchQuery) { newValue in + viewModel.search(query: newValue) + } + .searchable( + text: $searchQuery, + placement: .navigationBarDrawer(displayMode: .always), + prompt: L10n.search + ) + .backport + .searchFocused($isSearchFocused) + .onReceive(tabItemSelected) { event in + if event.isRepeat, event.isRoot { + isSearchFocused = true + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift new file mode 100644 index 00000000..847bc7ac --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift @@ -0,0 +1,79 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserGridButton: View { + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var label: some View { + VStack(alignment: .center) { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if selectedServer == nil { + // For layout, not to be localized + Text("Hidden") + .font(.footnote) + .hidden() + } + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift new file mode 100644 index 00000000..186a6dbf --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift @@ -0,0 +1,92 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserListRow: View { + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var rowContent: some View { + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var rowLeading: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 80) + .padding(.vertical, 8) + } + + @ViewBuilder + private var label: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + rowContent + } + .isSeparatorVisible(false) + .onSelect { + if let selectedServer { + action(selectedServer) + } + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift new file mode 100644 index 00000000..2f8b2f12 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -0,0 +1,103 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct ServerSelectionMenu: View { + + @Environment(\.colorScheme) + private var colorScheme + + @Router + private var router + + @Binding + private var serverSelection: SelectUserServerSelection + + let selectedServer: ServerState? + let servers: OrderedSet + + init( + selection: Binding, + selectedServer: ServerState?, + servers: OrderedSet + ) { + self._serverSelection = selection + self.selectedServer = selectedServer + self.servers = servers + } + + var body: some View { + Menu { + Section { + Button(L10n.addServer, systemImage: "plus") { + router.route(to: .connectToServer) + } + + if let selectedServer { + Button(L10n.editServer, systemImage: "server.rack") { + router.route( + to: .editServer(server: selectedServer, isEditing: true), + style: .sheet + ) + } + } + } + + Picker(L10n.servers, selection: _serverSelection) { + + if servers.count > 1 { + Label(L10n.allServers, systemImage: "person.2.fill") + .tag(SelectUserServerSelection.all) + } + + ForEach(servers.reversed()) { server in + Button { + Text(server.name) + Text(server.currentURL.absoluteString) + } + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } label: { + ZStack { + + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + + HStack { + switch serverSelection { + case .all: + Label(L10n.allServers, systemImage: "person.2.fill") + case let .server(id): + if let server = servers.first(where: { $0.id == id }) { + Label(server.name, systemImage: "server.rack") + } + } + + Image(systemName: "chevron.up.chevron.down") + .foregroundStyle(.secondary) + .font(.subheadline.weight(.semibold)) + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + } + .frame(height: 50) + .frame(maxWidth: 400) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift new file mode 100644 index 00000000..e67f9959 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/UserGridButton.swift @@ -0,0 +1,98 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserGridButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + var body: some View { + Button(action: action) { + VStack { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) + .overlay(alignment: .bottomTrailing) { + if isEditing, isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(1) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + .contextMenu { + if !isEditing { + Button( + L10n.delete, + role: .destructive, + action: onDelete + ) + } + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/UserListRow.swift b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift new file mode 100644 index 00000000..3c9fea55 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift @@ -0,0 +1,120 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserListRow: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + @ViewBuilder + private var personView: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + } + + @ViewBuilder + private var rowContent: some View { + HStack { + + VStack(alignment: .leading, spacing: 5) { + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundColor(Color(UIColor.lightGray)) + } + } + + Spacer() + + ListRowCheckbox() + } + } + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) + .frame(width: 80) + .padding(.vertical, 8) + } content: { + rowContent + } + .onSelect(perform: action) + .contextMenu { + Button(L10n.delete, role: .destructive) { + onDelete() + } + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift new file mode 100644 index 00000000..66c1e909 --- /dev/null +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -0,0 +1,605 @@ +// +// 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 LocalAuthentication +import SwiftUI + +// TODO: user ordering +// - name +// - last signed in date +// TODO: between the server selection menu and delete toolbar, +// figure out a way to make the grid/list and splash screen +// not jump when size is changed +// TODO: fix splash screen pulsing +// - should have used successful image source binding on ImageView? + +struct SelectUserView: View { + + typealias UserItem = (user: UserState, server: ServerState) + + // MARK: - Defaults + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + @Default(.selectUserServerSelection) + private var serverSelection + @Default(.selectUserDisplayType) + private var userListDisplayType + + // MARK: - State & Environment Objects + + @Router + private var router + + @State + private var isEditingUsers: Bool = false + @State + private var pin: String = "" + @State + private var selectedUsers: Set = [] + + // MARK: - Dialog States + + @State + private var isPresentingConfirmDeleteUsers = false + @State + private var isPresentingLocalPin: Bool = false + + @StateObject + private var viewModel = SelectUserViewModel() + + private var selectedServer: ServerState? { + serverSelection.server(from: viewModel.servers.keys) + } + + private var splashScreenImageSources: [ImageSource] { + switch (serverSelection, selectUserAllServersSplashscreen) { + case (.all, .all): + return viewModel + .servers + .keys + .shuffled() + .map(\.splashScreenImageSource) + + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + guard let server = viewModel + .servers + .keys + .first(where: { $0.id == id }) else { return [] } + + return [server.splashScreenImageSource] + } + } + + private var userItems: [UserItem] { + switch serverSelection { + case .all: + return viewModel.servers + .map { server, users in + users.map { (server: server, user: $0) } + } + .flattened() + .sorted(using: \.user.username) + .reversed() + .map { UserItem(user: $0.user, server: $0.server) } + case let .server(id: id): + guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { + return [] + } + + return viewModel.servers[server]! + .sorted(using: \.username) + .map { UserItem(user: $0, server: server) } + } + } + + private func addUserSelected(server: ServerState) { + UIDevice.impact(.light) + router.route(to: .userSignIn(server: server)) + } + + private func delete(user: UserState) { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + + // MARK: - Select User(s) + + // TODO: refactor errors thrown/handling + + private func select(user: UserState, needsPin: Bool = true) { + Task { @MainActor in + selectedUsers.insert(user) + + switch user.accessPolicy { + case .requireDeviceAuthentication: + try await performDeviceAuthentication(reason: L10n.userRequiresDeviceAuthentication(user.username)) + case .requirePin: + if needsPin { + isPresentingLocalPin = true + return + } + case .none: () + } + + await viewModel.signIn(user, pin: pin) + } + } + + // MARK: - Perform Device Authentication + + // TODO: move to view model + + // error logging/presentation is handled within here, just + // use try+thrown error in local Task for early return + private func performDeviceAuthentication(reason: String) async throws { + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { + viewModel.logger.critical("\(policyError!.localizedDescription)") + await viewModel.error(JellyfinAPIError(L10n.unableToPerformDeviceAuthFaceID)) + throw JellyfinAPIError(L10n.deviceAuthFailed) + } + + do { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } catch { + viewModel.logger.critical("\(error.localizedDescription)") + await viewModel.error(JellyfinAPIError(L10n.unableToPerformDeviceAuth)) + throw JellyfinAPIError(L10n.deviceAuthFailed) + } + } + + // MARK: - Advanced Menu + + @ViewBuilder + private var advancedMenu: some View { + Menu(L10n.advanced, systemImage: "gearshape.fill") { + + Section { + + if userItems.isNotEmpty { + ConditionalMenu( + tracking: selectedServer, + action: addUserSelected + ) { + Section(L10n.servers) { + let servers = viewModel.servers.keys + + ForEach(servers) { server in + Button { + addUserSelected(server: server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } + } label: { + Label(L10n.addUser, systemImage: "plus") + } + + Toggle( + L10n.editUsers, + systemImage: "person.crop.circle", + isOn: $isEditingUsers + ) + } + } + + if viewModel.servers.isNotEmpty { + Picker(selection: $userListDisplayType) { + ForEach(LibraryDisplayType.allCases, id: \.hashValue) { + Label($0.displayTitle, systemImage: $0.systemImage) + .tag($0) + } + } label: { + Text(L10n.layout) + Text(userListDisplayType.displayTitle) + Image(systemName: userListDisplayType.systemImage) + } + .pickerStyle(.menu) + } + + Section { + Button(L10n.advanced, systemImage: "gearshape.fill") { + router.route(to: .appSettings) + } + } + } + } + + @ViewBuilder + private var addUserGridButtonView: some View { + AddUserGridButton( + selectedServer: selectedServer, + servers: viewModel.servers.keys, + action: addUserSelected + ) + } + + @ViewBuilder + private func userGridItemView(for item: UserItem) -> some View { + let user = item.user + let server = item.server + + UserGridButton( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + delete(user: user) + } + .isSelected(selectedUsers.contains(user)) + } + + // MARK: - iPad Grid Content View + + @ViewBuilder + private var padGridContentView: some View { + if userItems.isEmpty { + CenteredLazyVGrid( + data: [0], + id: \.self, + minimum: 150, + maximum: 300, + spacing: EdgeInsets.edgePadding + ) { _ in + addUserGridButtonView + } + } else { + CenteredLazyVGrid( + data: userItems, + id: \.user.id, + minimum: 150, + maximum: 300, + spacing: EdgeInsets.edgePadding, + content: userGridItemView + ) + } + } + + // MARK: - iPhone Grid Content View + + @ViewBuilder + private var phoneGridContentView: some View { + if userItems.isEmpty { + CenteredLazyVGrid( + data: [0], + id: \.self, + columns: 2 + ) { _ in + addUserGridButtonView + } + } else { + CenteredLazyVGrid( + data: userItems, + id: \.user.id, + columns: 2, + spacing: EdgeInsets.edgePadding, + content: userGridItemView + ) + .edgePadding() + } + } + + // MARK: - List Content View + + @ViewBuilder + private var listContentView: some View { + List { + let userItems = self.userItems + + if userItems.isEmpty { + AddUserListRow( + selectedServer: selectedServer, + servers: viewModel.servers.keys, + action: addUserSelected + ) + .listRowBackground(EmptyView()) + .listRowInsets(.zero) + .listRowSeparator(.hidden) + } + + ForEach(userItems, id: \.user.id) { item in + let user = item.user + let server = item.server + + UserListRow( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + delete(user: user) + } + .isSelected(selectedUsers.contains(user)) + .swipeActions { + if !isEditingUsers { + Button( + L10n.delete, + systemImage: "trash" + ) { + delete(user: user) + } + .tint(.red) + } + } + } + .listRowBackground(EmptyView()) + .listRowInsets(.zero) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + } + + // MARK: - User View + + @ViewBuilder + private var contentView: some View { + VStack(spacing: 0) { + ZStack { + switch userListDisplayType { + case .grid: + Group { + if UIDevice.isPhone { + phoneGridContentView + } else { + padGridContentView + } + } + .scrollIfLargerThanContainer(padding: 100) + case .list: + listContentView + } + } + .animation(.linear(duration: 0.1), value: userListDisplayType) + .environment(\.isOverComplexContent, true) + .isEditing(isEditingUsers) + .frame(maxHeight: .infinity) + .mask { + VStack(spacing: 0) { + Color.white + + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 30) + } + } + + if !isEditingUsers { + ServerSelectionMenu( + selection: $serverSelection, + selectedServer: selectedServer, + servers: viewModel.servers.keys + ) + .edgePadding([.bottom, .horizontal]) + } + } + .background { + if selectUserUseSplashscreen, splashScreenImageSources.isNotEmpty { + ZStack { + Color.clear + + ImageView(splashScreenImageSources) + .pipeline(.Swiftfin.local) + .aspectRatio(contentMode: .fill) + .transition(.opacity.animation(.linear(duration: 0.1))) + .id(splashScreenImageSources) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } + + // MARK: - Connect to Server View + + @ViewBuilder + private var connectToServerView: some View { + VStack(spacing: 10) { + L10n.connectToJellyfinServerStart.text + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.connect) + .onSelect { + router.route(to: .connectToServer) + } + .frame(maxWidth: 300) + } + } + + // MARK: - Body + + var body: some View { + ZStack { + if viewModel.servers.isEmpty { + connectToServerView + } else { + contentView + } + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle(L10n.users) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Image(uiImage: .jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30) + } + + ToolbarItem(placement: .topBarLeading) { + if isEditingUsers { + if selectedUsers.count == userItems.count { + Button(L10n.removeAll) { + selectedUsers.removeAll() + } + .buttonStyle(.toolbarPill) + } else { + Button(L10n.selectAll) { + selectedUsers.insert(contentsOf: userItems.map(\.user)) + } + .buttonStyle(.toolbarPill) + } + } + } + + ToolbarItemGroup(placement: .topBarTrailing) { + if isEditingUsers { + Button(isEditingUsers ? L10n.cancel : L10n.edit) { + isEditingUsers.toggle() + + UIDevice.impact(.light) + + if !isEditingUsers { + selectedUsers.removeAll() + } + } + .buttonStyle(.toolbarPill) + } else { + advancedMenu + } + } + + ToolbarItem(placement: .bottomBar) { + if isEditingUsers { + Button(L10n.delete) { + isPresentingConfirmDeleteUsers = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedUsers.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .onAppear { + viewModel.getServers() + } + .onChange(of: isEditingUsers) { newValue in + guard !newValue else { return } + selectedUsers.removeAll() + } + .onChange(of: isPresentingConfirmDeleteUsers) { newValue in + guard !newValue else { return } + isEditingUsers = false + selectedUsers.removeAll() + } + .onChange(of: isPresentingLocalPin) { newValue in + guard newValue else { return } + pin = "" + } + .onChange(of: viewModel.servers.keys) { newValue in + if case let SelectUserServerSelection.server(id: id) = serverSelection, + !newValue.contains(where: { $0.id == id }) + { + if newValue.count == 1, let firstServer = newValue.first { + let newSelection = SelectUserServerSelection.server(id: firstServer.id) + serverSelection = newSelection + selectUserAllServersSplashscreen = newSelection + } else { + serverSelection = .all + selectUserAllServersSplashscreen = .all + } + } + } + .onReceive(viewModel.$error) { error in + guard error != nil else { return } + UIDevice.feedback(.error) + } + .onReceive(viewModel.events) { event in + switch event { + case let .signedIn(user): + UIDevice.feedback(.success) + + Defaults[.lastSignedInUserID] = .signedIn(userID: user.id) + Container.shared.currentUserSession.reset() + Notifications[.didSignIn].post() + } + } + .onNotification(.didConnectToServer) { server in + viewModel.getServers() + serverSelection = .server(id: server.id) + } + .onNotification(.didChangeCurrentServerURL) { _ in + viewModel.getServers() + } + .onNotification(.didDeleteServer) { _ in + viewModel.getServers() + } + .alert( + L10n.delete, + isPresented: $isPresentingConfirmDeleteUsers + ) { + Button(L10n.delete, role: .destructive) { + viewModel.deleteUsers(selectedUsers) + } + } message: { + if selectedUsers.count == 1, let first = selectedUsers.first { + Text(L10n.deleteUserSingleConfirmation(first.username)) + } else { + Text(L10n.deleteUserMultipleConfirmation(selectedUsers.count)) + } + } + .alert(L10n.signIn, isPresented: $isPresentingLocalPin) { + + TextField(L10n.pin, text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button(L10n.signIn) { + guard let user = selectedUsers.first else { + assertionFailure("User not selected") + return + } + + select(user: user, needsPin: false) + } + + Button(L10n.cancel, role: .cancel) {} + } message: { + if let user = selectedUsers.first, user.pinHint.isNotEmpty { + Text(user.pinHint) + } else { + let username = selectedUsers.first?.username ?? .emptyDash + + Text(L10n.enterPinForUser(username)) + } + } + .errorMessage($viewModel.error) + } +} diff --git a/Swiftfin/Views/ServerCheckView.swift b/Swiftfin/Views/ServerCheckView.swift new file mode 100644 index 00000000..26088e9a --- /dev/null +++ b/Swiftfin/Views/ServerCheckView.swift @@ -0,0 +1,79 @@ +// +// 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 ServerCheckView: View { + + @EnvironmentObject + private var rootCoordinator: RootCoordinator + + @Router + private var router + + @StateObject + private var viewModel = ServerCheckViewModel() + + @ViewBuilder + private func errorView(_ error: some Error) -> some View { + VStack(spacing: 10) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + + Text(viewModel.userSession.server.name) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + Text(error.localizedDescription) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.retry) + .onSelect { + viewModel.checkServer() + } + .frame(maxWidth: 300) + .frame(height: 50) + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .initial: + ZStack { + Color.clear + + ProgressView() + } + case .error: + viewModel.error.map { errorView($0) } + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .onFirstAppear { + viewModel.checkServer() + } + .onReceive(viewModel.events) { event in + switch event { + case .connected: + rootCoordinator.root(.mainTab) + } + } + .topBarTrailing { + + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + router.route(to: .settings) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift new file mode 100644 index 00000000..0060044a --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift @@ -0,0 +1,67 @@ +// +// 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 Foundation +import SwiftUI + +extension CustomDeviceProfileSettingsView { + + struct CustomProfileButton: View { + + let profile: CustomDeviceProfile + let onSelect: () -> Void + + @ViewBuilder + private func profileDetailsView(title: String, detail: String) -> some View { + VStack(alignment: .leading) { + Text(title) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + Text(detail) + .foregroundColor(.secondary) + } + .font(.subheadline) + } + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 8) { + profileDetailsView( + title: L10n.audio, + detail: profile.audio.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.video, + detail: profile.video.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.containers, + detail: profile.container.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.useAsTranscodingProfile, + detail: profile.useAsTranscodingProfile ? L10n.yes : L10n.no + ) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary) + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift new file mode 100644 index 00000000..20c50ae8 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift @@ -0,0 +1,146 @@ +// +// 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 CustomDeviceProfileSettingsView { + + struct EditCustomDeviceProfileView: View { + + @Default(.accentColor) + private var accentColor + + @StoredValue(.User.customDeviceProfiles) + private var customDeviceProfiles + + @Router + private var router + + @State + private var isPresentingNotSaved = false + @State + private var profile: CustomDeviceProfile + + private let createProfile: Bool + private let source: Binding? + + private var isValid: Bool { + profile.audio.isNotEmpty && + profile.video.isNotEmpty && + profile.container.isNotEmpty + } + + init(profile: Binding?) { + + createProfile = profile == nil + + if let profile { + self._profile = State(initialValue: profile.wrappedValue) + self.source = profile + } else { + self._profile = State(initialValue: .init(type: .video)) + self.source = nil + } + } + + @ViewBuilder + private func codecSection( + title: String, + content: String, + onSelect: @escaping () -> Void + ) -> some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + if content.isEmpty { + Label(L10n.none, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + .foregroundColor(.secondary) + } else { + Text(content) + .foregroundColor(.secondary) + } + } + .font(.subheadline) + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary) + } + + var body: some View { + Form { + Toggle(L10n.useAsTranscodingProfile, isOn: $profile.useAsTranscodingProfile) + + Section { + codecSection( + title: L10n.audio, + content: profile.audio.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileAudio(selection: $profile.audio)) + } + + codecSection( + title: L10n.video, + content: profile.video.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileVideo(selection: $profile.video)) + } + + codecSection( + title: L10n.containers, + content: profile.container.map(\.displayTitle).joined(separator: ", ") + ) { + router.route(to: .editCustomDeviceProfileContainer(selection: $profile.container)) + } + } footer: { + if !isValid { + Label(L10n.missingCodecValues, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .navigationBarCloseButton { + isPresentingNotSaved = true + } + .navigationTitle(L10n.customProfile) + .topBarTrailing { + Button(L10n.save) { + if createProfile { + customDeviceProfiles.append(profile) + } else { + source?.wrappedValue = profile + } + + UIDevice.impact(.light) + router.dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + .alert(L10n.profileNotSaved, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { + router.dismiss() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift new file mode 100644 index 00000000..54c795ea --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift @@ -0,0 +1,83 @@ +// +// 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 SwiftUI + +struct CustomDeviceProfileSettingsView: View { + + @Default(.VideoPlayer.Playback.customDeviceProfileAction) + private var customDeviceProfileAction + + @StoredValue(.User.customDeviceProfiles) + private var customProfiles: [CustomDeviceProfile] + + @Router + private var router + + private var isValid: Bool { + customDeviceProfileAction == .add || + customProfiles.isNotEmpty + } + + private func removeProfile(at offsets: IndexSet) { + customProfiles.remove(atOffsets: offsets) + } + + var body: some View { + List { + Section { + CaseIterablePicker( + L10n.behavior, + selection: $customDeviceProfileAction + ) + } footer: { + VStack(spacing: 8) { + switch customDeviceProfileAction { + case .add: + L10n.customDeviceProfileAdd.text + case .replace: + L10n.customDeviceProfileReplace.text + } + + if !isValid { + Label(L10n.noDeviceProfileWarning, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + + Section(L10n.profiles) { + + if customProfiles.isEmpty { + Button(L10n.add) { + router.route(to: .createCustomDeviceProfile) + } + } + + ForEach($customProfiles, id: \.self) { $profile in + CustomProfileButton(profile: profile) { + router.route(to: .editCustomDeviceProfile(profile: $profile)) + } + } + .onDelete(perform: removeProfile) + } + } + .navigationTitle(L10n.profiles) + .topBarTrailing { + if customProfiles.isNotEmpty { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: .createCustomDeviceProfile) + } + .buttonStyle(.toolbarPill) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift new file mode 100644 index 00000000..9ac645d7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension CustomizeViewsSettings { + + struct HomeSection: View { + + @Default(.Customization.Home.showRecentlyAdded) + private var showRecentlyAdded + @Default(.Customization.Home.maxNextUp) + private var maxNextUp + @Default(.Customization.Home.resumeNextUp) + private var resumeNextUp + + var body: some View { + Section(L10n.home) { + + Toggle(L10n.showRecentlyAdded, isOn: $showRecentlyAdded) + + Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) + + ChevronButton( + L10n.nextUpDays, + subtitle: { + if maxNextUp > 0 { + let duration = Duration.seconds(TimeInterval(maxNextUp)) + return Text(duration, format: .units(allowed: [.days], width: .abbreviated)) + } else { + return Text(L10n.disabled) + } + }(), + description: L10n.nextUpDaysDescription + ) { + TextField( + L10n.days, + value: $maxNextUp, + format: .dayInterval(range: 0 ... 1000) + ) + .keyboardType(.numberPad) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift new file mode 100644 index 00000000..74f64e03 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -0,0 +1,65 @@ +// +// 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 SwiftUI + +extension CustomizeViewsSettings { + + struct ItemSection: View { + + @Injected(\.currentUserSession) + private var userSession + + @Router + private var router + + @StoredValue(.User.itemViewAttributes) + private var itemViewAttributes + @StoredValue(.User.enabledTrailers) + private var enabledTrailers + + @StoredValue(.User.enableItemEditing) + private var enableItemEditing + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement + + var body: some View { + Section(L10n.items) { + + ChevronButton(L10n.mediaAttributes) { + router.route(to: .itemViewAttributes(selection: $itemViewAttributes)) + } + + CaseIterablePicker( + L10n.enabledTrailers, + selection: $enabledTrailers + ) + + /// Enabled Collection Management for collection managers + if userSession?.user.permissions.items.canManageCollections == true { + Toggle(L10n.editCollections, isOn: $enableCollectionManagement) + } + /// Enabled Media Management when there are media elements that can be managed + if userSession?.user.permissions.items.canEditMetadata == true || + userSession?.user.permissions.items.canManageLyrics == true || + userSession?.user.permissions.items.canManageSubtitles == true + { + Toggle(L10n.editMedia, isOn: $enableItemEditing) + } + /// Enabled Media Deletion for valid deletion users + if userSession?.user.permissions.items.canDelete == true { + Toggle(L10n.deleteMedia, isOn: $enableItemDeletion) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift new file mode 100644 index 00000000..ab9e1c41 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -0,0 +1,188 @@ +// +// 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 + +// TODO: will be entirely re-organized + +struct CustomizeViewsSettings: View { + + @Default(.Customization.itemViewType) + private var itemViewType + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var cinematicItemViewTypeUsePrimaryImage + + @Default(.Customization.shouldShowMissingSeasons) + private var shouldShowMissingSeasons + @Default(.Customization.shouldShowMissingEpisodes) + private var shouldShowMissingEpisodes + + @Default(.Customization.Library.letterPickerEnabled) + var letterPickerEnabled + @Default(.Customization.Library.letterPickerOrientation) + var letterPickerOrientation + @Default(.Customization.Library.enabledDrawerFilters) + private var libraryEnabledDrawerFilters + @Default(.Customization.Search.enabledDrawerFilters) + private var searchEnabledDrawerFilters + + @Default(.Customization.showPosterLabels) + private var showPosterLabels + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + private var showRecentlyAdded + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + @Default(.Customization.similarPosterType) + private var similarPosterType + @Default(.Customization.searchPosterType) + private var searchPosterType + @Default(.Customization.Library.displayType) + private var libraryDisplayType + @Default(.Customization.Library.posterType) + private var libraryPosterType + @Default(.Customization.Library.listColumnCount) + private var listColumnCount + + @Default(.Customization.Library.rememberLayout) + private var rememberLibraryLayout + @Default(.Customization.Library.rememberSort) + private var rememberLibrarySort + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + @Default(.Customization.Library.showFavorites) + private var showFavorites + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + + @Router + private var router + + var body: some View { + List { + + if UIDevice.isPhone { + Section { + CaseIterablePicker(L10n.items, selection: $itemViewType) + } + + if itemViewType == .cinematic { + Section { + Toggle(L10n.usePrimaryImage, isOn: $cinematicItemViewTypeUsePrimaryImage) + } footer: { + L10n.usePrimaryImageDescription.text + } + } + } + + Section { + + Toggle(L10n.favorites, isOn: $showFavorites) + Toggle(L10n.randomImage, isOn: $libraryRandomImage) + + } header: { + L10n.library.text + } + + Section { + + Toggle(L10n.letterPicker, isOn: $letterPickerEnabled) + + if letterPickerEnabled { + CaseIterablePicker( + L10n.orientation, + selection: $letterPickerOrientation + ) + } + + ChevronButton(L10n.library) { + router.route(to: .itemFilterDrawerSelector(selection: $libraryEnabledDrawerFilters)) + } + + ChevronButton(L10n.search) { + router.route(to: .itemFilterDrawerSelector(selection: $searchEnabledDrawerFilters)) + } + + } header: { + L10n.filters.text + } + + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + + Section(L10n.posters) { + + ChevronButton(L10n.indicators) { + router.route(to: .indicatorSettings) + } + + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + + CaseIterablePicker(L10n.next, selection: $nextUpPosterType) + .onlySupportedCases(true) + + CaseIterablePicker(L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) + .onlySupportedCases(true) + + CaseIterablePicker(L10n.recommended, selection: $similarPosterType) + .onlySupportedCases(true) + + CaseIterablePicker(L10n.search, selection: $searchPosterType) + .onlySupportedCases(true) + } + + Section(L10n.libraries) { + CaseIterablePicker(L10n.library, selection: $libraryDisplayType) + + CaseIterablePicker(L10n.posters, selection: $libraryPosterType) + .onlySupportedCases(true) + + if libraryDisplayType == .list, UIDevice.isPad { + BasicStepper( + L10n.columns, + value: $listColumnCount, + range: 1 ... 4, + step: 1 + ) + } + } + + ItemSection() + + HomeSection() + + Section { + Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) + } footer: { + Text(L10n.rememberLayoutFooter) + } + + Section { + Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) + } footer: { + Text(L10n.rememberSortingFooter) + } + + Section { + Toggle(L10n.seriesBackdrop, isOn: $useSeriesLandscapeBackdrop) + } header: { + // TODO: think of a better name + L10n.episodeLandscapePoster.text + } + } + .navigationTitle(L10n.customize) + } +} diff --git a/Swiftfin/Views/SettingsView/DebugSettingsView.swift b/Swiftfin/Views/SettingsView/DebugSettingsView.swift new file mode 100644 index 00000000..ddf2c131 --- /dev/null +++ b/Swiftfin/Views/SettingsView/DebugSettingsView.swift @@ -0,0 +1,28 @@ +// +// 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 + +// NOTE: All settings *MUST* be surrounded by DEBUG compiler conditional as usage site + +#if DEBUG +struct DebugSettingsView: View { + + @Default(.sendProgressReports) + private var sendProgressReports + + var body: some View { + Form { + + Toggle("Send Progress Reports", isOn: $sendProgressReports) + } + .navigationTitle("Debug") + } +} +#endif diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift new file mode 100644 index 00000000..9101f4d3 --- /dev/null +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -0,0 +1,21 @@ +// +// 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 + +// Note: Used for experimental settings that may be removed or implemented +// officially. Keep for future settings. + +struct ExperimentalSettingsView: View { + + var body: some View { + Form {} + .navigationTitle(L10n.experimental) + } +} diff --git a/Swiftfin/Views/SettingsView/GestureSettingsView.swift b/Swiftfin/Views/SettingsView/GestureSettingsView.swift new file mode 100644 index 00000000..78ca00c5 --- /dev/null +++ b/Swiftfin/Views/SettingsView/GestureSettingsView.swift @@ -0,0 +1,64 @@ +// +// 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 + +// TODO: organize into a better structure +// TODO: add footer descriptions to each explaining the +// the gesture + why horizontal pan/swipe caveat +// TODO: add page describing each action? + +struct GestureSettingsView: View { + + @Default(.VideoPlayer.Gesture.horizontalPanAction) + private var horizontalPanAction + @Default(.VideoPlayer.Gesture.horizontalSwipeAction) + private var horizontalSwipeAction + @Default(.VideoPlayer.Gesture.longPressAction) + private var longPressGesture + @Default(.VideoPlayer.Gesture.multiTapGesture) + private var multiTapGesture + @Default(.VideoPlayer.Gesture.doubleTouchGesture) + private var doubleTouchGesture + @Default(.VideoPlayer.Gesture.pinchGesture) + private var pinchGesture + @Default(.VideoPlayer.Gesture.verticalPanLeftAction) + private var verticalPanLeftAction + @Default(.VideoPlayer.Gesture.verticalPanRightAction) + private var verticalPanRightAction + + var body: some View { + Form { + + Section { + + // TODO: make toggle sections + + CaseIterablePicker(L10n.horizontalPan, selection: $horizontalPanAction) + .disabled(horizontalSwipeAction != .none) + + CaseIterablePicker(L10n.horizontalSwipe, selection: $horizontalSwipeAction) + .disabled(horizontalPanAction != .none) + + CaseIterablePicker(L10n.longPress, selection: $longPressGesture) + + CaseIterablePicker(L10n.multiTap, selection: $multiTapGesture) + + CaseIterablePicker(L10n.doubleTouch, selection: $doubleTouchGesture) + + CaseIterablePicker(L10n.pinch, selection: $pinchGesture) + + CaseIterablePicker(L10n.leftVerticalPan, selection: $verticalPanLeftAction) + + CaseIterablePicker(L10n.rightVerticalPan, selection: $verticalPanRightAction) + } + } + .navigationTitle(L10n.gestures) + } +} diff --git a/Swiftfin/Views/SettingsView/IndicatorSettingsView 2.swift b/Swiftfin/Views/SettingsView/IndicatorSettingsView 2.swift new file mode 100644 index 00000000..5cbea284 --- /dev/null +++ b/Swiftfin/Views/SettingsView/IndicatorSettingsView 2.swift @@ -0,0 +1,40 @@ +// +// 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 + +// TODO: show a sample poster to model indicators + +struct IndicatorSettingsView: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + var body: some View { + Form { + Section { + + Toggle(L10n.favorited, isOn: $showFavorited) + + Toggle(L10n.progress, isOn: $showProgress) + + Toggle(L10n.unplayed, isOn: $showUnplayed) + + Toggle(L10n.played, isOn: $showPlayed) + } + } + .navigationTitle(L10n.indicators) + } +} diff --git a/Swiftfin/Views/SettingsView/IndicatorSettingsView.swift b/Swiftfin/Views/SettingsView/IndicatorSettingsView.swift new file mode 100644 index 00000000..5cbea284 --- /dev/null +++ b/Swiftfin/Views/SettingsView/IndicatorSettingsView.swift @@ -0,0 +1,40 @@ +// +// 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 + +// TODO: show a sample poster to model indicators + +struct IndicatorSettingsView: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + var body: some View { + Form { + Section { + + Toggle(L10n.favorited, isOn: $showFavorited) + + Toggle(L10n.progress, isOn: $showProgress) + + Toggle(L10n.unplayed, isOn: $showUnplayed) + + Toggle(L10n.played, isOn: $showPlayed) + } + } + .navigationTitle(L10n.indicators) + } +} diff --git a/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift new file mode 100644 index 00000000..8781fd60 --- /dev/null +++ b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct NativeVideoPlayerSettingsView: View { + + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + var body: some View { + Form { + + Section { + + BasicStepper( + L10n.resumeOffset, + value: $resumeOffset, + range: 0 ... 30, + step: 1, + formatter: SecondFormatter() + ) + } footer: { + Text(L10n.resumeOffsetDescription) + } + } + .navigationTitle(L10n.nativePlayer) + } +} diff --git a/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift new file mode 100644 index 00000000..987fd919 --- /dev/null +++ b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift @@ -0,0 +1,107 @@ +// +// 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 + +struct PlaybackQualitySettingsView: View { + + @Default(.VideoPlayer.Playback.appMaximumBitrate) + private var appMaximumBitrate + @Default(.VideoPlayer.Playback.appMaximumBitrateTest) + private var appMaximumBitrateTest + @Default(.VideoPlayer.Playback.compatibilityMode) + private var compatibilityMode + + @Router + private var router + + var body: some View { + Form { + Section { + CaseIterablePicker( + L10n.maximumBitrate, + selection: $appMaximumBitrate + ) + } header: { + L10n.bitrateDefault.text + } footer: { + VStack(alignment: .leading) { + Text(L10n.bitrateDefaultDescription) + LearnMoreButton(L10n.bitrateDefault) { + LabeledContent( + L10n.auto, + value: L10n.birateAutoDescription + ) + LabeledContent( + L10n.bitrateMax, + value: L10n.bitrateMaxDescription(PlaybackBitrate.max.rawValue.formatted(.bitRate)) + ) + } + } + } + .animation(.none, value: appMaximumBitrate) + + if appMaximumBitrate == .auto { + Section { + CaseIterablePicker( + L10n.testSize, + selection: $appMaximumBitrateTest + ) + } header: { + L10n.bitrateTest.text + } footer: { + VStack(alignment: .leading) { + L10n.bitrateTestDisclaimer.text + } + } + } + + Section { + CaseIterablePicker( + L10n.compatibility, + selection: $compatibilityMode + ) + .animation(.none, value: compatibilityMode) + + if compatibilityMode == .custom { + ChevronButton(L10n.profiles) { + router.route(to: .customDeviceProfileSettings) + } + } + } header: { + Text(L10n.deviceProfile) + } footer: { + VStack(alignment: .leading) { + Text(L10n.deviceProfileDescription) + LearnMoreButton(L10n.deviceProfile) { + LabeledContent( + L10n.auto, + value: L10n.autoDescription + ) + LabeledContent( + L10n.compatible, + value: L10n.compatibleDescription + ) + LabeledContent( + L10n.direct, + value: L10n.directDescription + ) + LabeledContent( + L10n.custom, + value: L10n.customDescription + ) + } + } + } + } + .animation(.linear, value: appMaximumBitrate) + .animation(.linear, value: compatibilityMode) + .navigationTitle(L10n.playbackQuality) + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift new file mode 100644 index 00000000..8acb1c56 --- /dev/null +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -0,0 +1,129 @@ +// +// 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 JellyfinAPI + +import SwiftUI + +struct SettingsView: View { + + @Default(.userAccentColor) + private var accentColor + @Default(.userAppearance) + private var appearance + @Default(.VideoPlayer.videoPlayerType) + private var videoPlayerType + + @Router + private var router + + @StateObject + private var viewModel = SettingsViewModel() + + var body: some View { + Form { + Section { + + UserProfileRow(user: viewModel.userSession.user.data) { + router.route(to: .userProfile(viewModel: viewModel)) + } + + ChevronButton( + L10n.server, + action: { + router.route(to: .editServer(server: viewModel.userSession.server)) + }, + icon: { EmptyView() }, + subtitle: { + Label { + Text(viewModel.userSession.server.name) + } icon: { + if !viewModel.userSession.server.isVersionCompatible { + Image(systemName: "exclamationmark.circle.fill") + } + } + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + ) + + if viewModel.userSession.user.permissions.isAdministrator { + ChevronButton(L10n.dashboard) { + router.route(to: .adminDashboard) + } + } + } + + ListRowButton(L10n.switchUser) { + UIDevice.impact(.medium) + + viewModel.signOut() + router.dismiss() + } + .foregroundStyle(accentColor.overlayColor, accentColor) + + Section(L10n.videoPlayer) { + CaseIterablePicker( + L10n.videoPlayerType, + selection: $videoPlayerType + ) + + ChevronButton(L10n.nativePlayer) { + router.route(to: .nativePlayerSettings) + } + + ChevronButton(L10n.videoPlayer) { + router.route(to: .videoPlayerSettings) + } + + ChevronButton(L10n.playbackQuality) { + router.route(to: .playbackQualitySettings) + } + } + + Section(L10n.accessibility) { + CaseIterablePicker(L10n.appearance, selection: $appearance) + + ChevronButton(L10n.customize) { + router.route(to: .customizeViewsSettings) + } + + // Note: uncomment if there are current + // experimental settings + +// ChevronButton(L10n.experimental) +// .onSelect { +// router.route(to: .experimentalSettings) +// } + } + + Section { + ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) + } footer: { + Text(L10n.viewsMayRequireRestart) + } + + ChevronButton(L10n.logs) { + router.route(to: .log) + } + + #if DEBUG + + ChevronButton("Debug") { + router.route(to: .debugSettings) + } + + #endif + } + .navigationTitle(L10n.settings) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift new file mode 100644 index 00000000..0ed5e1e8 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift @@ -0,0 +1,149 @@ +// +// 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 Foundation +import JellyfinAPI +import SwiftUI + +struct QuickConnectAuthorizeView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Focus Fields + + @FocusState + private var isCodeFocused: Bool + + @Router + private var router + + // MARK: - State & Environment Objects + + @StateObject + private var viewModel: QuickConnectAuthorizeViewModel + + // MARK: - Quick Connect Variables + + @State + private var code: String = "" + + // MARK: - Dialog State + + @State + private var isPresentingSuccess: Bool = false + + // MARK: - Initialize + + init(user: UserDto) { + self._viewModel = StateObject(wrappedValue: QuickConnectAuthorizeViewModel(user: user)) + } + + // MARK: Display the User Being Authenticated + + @ViewBuilder + private var loginUserRow: some View { + HStack { + UserProfileImage( + userID: viewModel.user.id, + source: viewModel.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 120 + ) + ) + .frame(width: 50, height: 50) + + Text(viewModel.user.name ?? L10n.unknown) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + Spacer() + } + } + + // MARK: - Body + + var body: some View { + Form { + Section { + loginUserRow + } header: { + Text(L10n.user) + } footer: { + Text(L10n.quickConnectUserDisclaimer) + } + + Section { + TextField(L10n.quickConnectCode, text: $code) + .keyboardType(.numberPad) + .disabled(viewModel.state == .authorizing) + .focused($isCodeFocused) + } footer: { + Text(L10n.quickConnectCodeInstruction) + } + + if viewModel.state == .authorizing { + ListRowButton(L10n.cancel, role: .cancel) { + viewModel.cancel() + isCodeFocused = true + } + } else { + ListRowButton(L10n.authorize) { + viewModel.authorize(code: code) + } + .disabled(code.count != 6 || viewModel.state == .authorizing) + .foregroundStyle( + accentColor.overlayColor, + accentColor + ) + .opacity(code.count != 6 ? 0.5 : 1) + } + } + .interactiveDismissDisabled(viewModel.state == .authorizing) + .navigationBarBackButtonHidden(viewModel.state == .authorizing) + .navigationTitle(L10n.quickConnect.text) + .onFirstAppear { + isCodeFocused = true + } + .onChange(of: code) { newValue in + code = String(newValue.prefix(6)) + } + .onReceive(viewModel.$error) { error in + guard error != nil else { return } + UIDevice.feedback(.error) + } + .onReceive(viewModel.events) { event in + switch event { + case .authorized: + UIDevice.feedback(.success) + isPresentingSuccess = true + } + } + .topBarTrailing { + if viewModel.state == .authorizing { + ProgressView() + } + } + .alert( + L10n.quickConnect, + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) { + router.dismiss() + } + } message: { + L10n.quickConnectSuccessMessage.text + } + .errorMessage($viewModel.error) { + isCodeFocused = true + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift new file mode 100644 index 00000000..8f2cf42f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift @@ -0,0 +1,278 @@ +// +// 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 Combine +import Defaults +import KeychainSwift +import LocalAuthentication +import SwiftUI + +// TODO: present toast when authentication successfully changed +// TODO: pop is just a workaround to get change published from usersession. +// find fix and don't pop when successfully changed +// TODO: could cleanup/refactor greatly +// TODO: change footer list descriptions into a `Learn More...` modal popup + +struct UserLocalSecurityView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - State & Environment Objects + + @Router + private var router + + @StateObject + private var viewModel = UserLocalSecurityViewModel() + + // MARK: - Local Security Variables + + @State + private var listSize: CGSize = .zero + @State + private var onPinCompletion: (() -> Void)? + @State + private var pin: String = "" + @State + private var pinHint: String = "" + @State + private var signInPolicy: UserAccessPolicy = .none + + // MARK: - Dialog States + + @State + private var isPresentingOldPinPrompt: Bool = false + @State + private var isPresentingNewPinPrompt: Bool = false + + // MARK: - Error State + + @State + private var error: Error? = nil + + // MARK: - Check Old Policy + + private func checkOldPolicy() { + do { + try viewModel.checkForOldPolicy() + } catch { + return + } + + checkNewPolicy() + } + + // MARK: - Check New Policy + + private func checkNewPolicy() { + do { + try viewModel.checkFor(newPolicy: signInPolicy) + } catch { + return + } + + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + } + + // MARK: - Perform Device Authentication + + // error logging/presentation is handled within here, just + // use try+thrown error in local Task for early return + private func performDeviceAuthentication(reason: String) async throws { + let context = LAContext() + var policyError: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { + viewModel.logger.critical("\(policyError!.localizedDescription)") + + await MainActor.run { + self + .error = JellyfinAPIError(L10n.unableToPerformDeviceAuthFaceID) + } + + throw JellyfinAPIError(L10n.deviceAuthFailed) + } + + do { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } catch { + viewModel.logger.critical("\(error.localizedDescription)") + + await MainActor.run { + self.error = JellyfinAPIError(L10n.unableToPerformDeviceAuth) + } + + throw JellyfinAPIError(L10n.deviceAuthFailed) + } + } + + // MARK: - Body + + var body: some View { + List { + + Section { + CaseIterablePicker(L10n.security, selection: $signInPolicy) + } footer: { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.additionalSecurityAccessDescription) + + // frame necessary with bug within BulletedList + BulletedList { + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) + .fontWeight(.semibold) + + Text(L10n.requireDeviceAuthDescription) + } + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.requirePin.displayTitle) + .fontWeight(.semibold) + + Text(L10n.requirePinDescription) + } + + VStack(alignment: .leading, spacing: 5) { + Text(UserAccessPolicy.none.displayTitle) + .fontWeight(.semibold) + + Text(L10n.saveUserWithoutAuthDescription) + } + } + .frame(width: max(10, listSize.width - 50)) + } + } + + if signInPolicy == .requirePin { + Section { + TextField(L10n.hint, text: $pinHint) + } header: { + Text(L10n.hint) + } footer: { + Text(L10n.setPinHintDescription) + } + } + } + .animation(.linear, value: signInPolicy) + .navigationTitle(L10n.security) + .navigationBarTitleDisplayMode(.inline) + .onFirstAppear { + pinHint = viewModel.userSession.user.pinHint + signInPolicy = viewModel.userSession.user.accessPolicy + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .promptForOldDeviceAuth: + Task { @MainActor in + try await performDeviceAuthentication( + reason: L10n.userRequiresDeviceAuthentication(viewModel.userSession.user.username) + ) + + checkNewPolicy() + } + case .promptForOldPin: + onPinCompletion = { + Task { + try viewModel.check(oldPin: pin) + + checkNewPolicy() + } + } + + pin = "" + isPresentingOldPinPrompt = true + case .promptForNewDeviceAuth: + Task { @MainActor in + try await performDeviceAuthentication( + reason: L10n.userRequiresDeviceAuthentication(viewModel.userSession.user.username) + ) + + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: "") + router.dismiss() + } + case .promptForNewPin: + onPinCompletion = { + viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) + router.dismiss() + } + + pin = "" + isPresentingNewPinPrompt = true + } + } + .topBarTrailing { + Button { + checkOldPolicy() + } label: { + Group { + if signInPolicy == .requirePin, signInPolicy == viewModel.userSession.user.accessPolicy { + Text(L10n.changePin) + } else { + Text(L10n.save) + } + } + .foregroundStyle(accentColor.overlayColor) + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + accentColor + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .trackingSize($listSize) + .alert( + L10n.enterPin, + isPresented: $isPresentingOldPinPrompt, + presenting: onPinCompletion + ) { completion in + + TextField(L10n.pin, text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button(L10n.continue) { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text(L10n.enterPinForUser(viewModel.userSession.user.username)) + } + .alert( + L10n.setPin, + isPresented: $isPresentingNewPinPrompt, + presenting: onPinCompletion + ) { completion in + + TextField(L10n.pin, text: $pin) + .keyboardType(.numberPad) + + // bug in SwiftUI: having .disabled will dismiss + // alert but not call the closure (for length) + Button(L10n.set) { + completion() + } + + Button(L10n.cancel, role: .cancel) {} + } message: { _ in + Text(L10n.createPinForUser(viewModel.userSession.user.username)) + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift new file mode 100644 index 00000000..cdaca91f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift @@ -0,0 +1,89 @@ +// +// 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 + +struct UserProfileSettingsView: View { + + @Router + private var router + + @ObservedObject + private var viewModel: SettingsViewModel + @StateObject + private var profileImageViewModel: UserProfileImageViewModel + + @State + private var isPresentingConfirmReset: Bool = false + + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data)) + } + + var body: some View { + List { + UserProfileHeroImage( + user: profileImageViewModel.user, + source: viewModel.userSession.user.profileImageSource( + client: viewModel.userSession.client, + maxWidth: 150 + ) + ) { + router.route(to: .userProfileImage(viewModel: profileImageViewModel)) + } onDelete: { + profileImageViewModel.send(.delete) + } + + Section { + ChevronButton(L10n.quickConnect) { + router.route(to: .quickConnectAuthorize(user: viewModel.userSession.user.data)) + } + + ChevronButton(L10n.password) { + router.route(to: .resetUserPassword(userID: viewModel.userSession.user.id)) + } + } + + Section { + ChevronButton(L10n.security) { + router.route(to: .localSecurity) + } + } + + Section { + // TODO: move under future "Storage" tab + // when downloads implemented + Button(L10n.resetSettings) { + isPresentingConfirmReset = true + } + .foregroundStyle(.red) + } footer: { + Text(L10n.resetSettingsDescription) + } + } + .confirmationDialog( + L10n.resetSettings, + isPresented: $isPresentingConfirmReset, + titleVisibility: .visible + ) { + Button(L10n.reset, role: .destructive) { + do { + try viewModel.userSession.user.deleteSettings() + } catch { + viewModel.logger.error("Unable to reset user settings: \(error.localizedDescription)") + } + } + } message: { + Text(L10n.resetSettingsMessage) + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift new file mode 100644 index 00000000..38eaeb66 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift @@ -0,0 +1,30 @@ +// +// 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 + +struct ActionButtonSelectorView: View { + + @Binding + var selection: [VideoPlayerActionButton] + + var body: some View { + OrderedSectionSelectorView( + selection: $selection, + sources: VideoPlayerActionButton.allCases + ) + .label { button in + HStack { + Image(systemName: button.systemImage) + + Text(button.displayTitle) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift new file mode 100644 index 00000000..814683b0 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift @@ -0,0 +1,45 @@ +// +// 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 VideoPlayerSettingsView { + + struct ButtonSection: View { + + @Default(.VideoPlayer.barActionButtons) + private var barActionButtons + @Default(.VideoPlayer.menuActionButtons) + private var menuActionButtons + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @Router + private var router + + var body: some View { + Section(L10n.buttons) { + + ChevronButton(L10n.barButtons) { + router.route(to: .actionButtonSelector(selectedButtonsBinding: $barActionButtons)) + } + + ChevronButton(L10n.menuButtons) { + router.route(to: .actionButtonSelector(selectedButtonsBinding: $menuActionButtons)) + } + } + .onChange(of: barActionButtons) { newValue in + autoPlayEnabled = newValue.contains(.autoPlay) || menuActionButtons.contains(.autoPlay) + } + .onChange(of: menuActionButtons) { newValue in + autoPlayEnabled = newValue.contains(.autoPlay) || barActionButtons.contains(.autoPlay) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift new file mode 100644 index 00000000..69b9fb6a --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift @@ -0,0 +1,33 @@ +// +// 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 VideoPlayerSettingsView { + struct SliderSection: View { + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + + @StoredValue(.User.previewImageScrubbing) + private var previewImageScrubbing: PreviewImageScrubbingOption + + var body: some View { + Section(L10n.slider) { + + Toggle(L10n.chapterSlider, isOn: $chapterSlider) + + CaseIterablePicker( + "Preview Image", + selection: $previewImageScrubbing + ) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift new file mode 100644 index 00000000..a74bbef7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift @@ -0,0 +1,48 @@ +// +// 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 VideoPlayerSettingsView { + struct SubtitleSection: View { + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + @Default(.VideoPlayer.Subtitle.subtitleSize) + private var subtitleSize + @Default(.VideoPlayer.Subtitle.subtitleColor) + private var subtitleColor + + @Router + private var router + + var body: some View { + Section { + ChevronButton(L10n.subtitleFont, subtitle: subtitleFontName) { + router.route(to: .fontPicker(selection: $subtitleFontName)) + } + + BasicStepper( + L10n.subtitleSize, + value: $subtitleSize, + range: 1 ... 20, + step: 1 + ) + + ColorPicker(selection: $subtitleColor, supportsOpacity: false) { + Text(L10n.subtitleColor) + } + } header: { + Text(L10n.subtitle) + } footer: { + // TODO: better wording + Text(L10n.subtitlesDisclaimer) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift new file mode 100644 index 00000000..de5ff422 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift @@ -0,0 +1,25 @@ +// +// 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 VideoPlayerSettingsView { + struct TimestampSection: View { + + @Default(.VideoPlayer.Overlay.trailingTimestampType) + private var trailingTimestampType + + var body: some View { + Section(L10n.timestamp) { + + CaseIterablePicker(L10n.trailingValue, selection: $trailingTimestampType) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift new file mode 100644 index 00000000..e243fcc5 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift @@ -0,0 +1,59 @@ +// +// 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 + +struct VideoPlayerSettingsView: View { + + @Default(.VideoPlayer.jumpBackwardInterval) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardInterval) + private var jumpForwardLength + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + @Router + private var router + + var body: some View { + Form { + + ChevronButton(L10n.gestures) { + router.route(to: .gestureSettings) + } + + // TODO: custom view for custom interval creation +// CaseIterablePicker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) + +// CaseIterablePicker(L10n.jumpForwardLength, selection: $jumpForwardLength) + + Section { + + BasicStepper( + L10n.resumeOffset, + value: $resumeOffset, + range: 0 ... 30, + step: 1, + formatter: SecondFormatter() + ) + } footer: { + Text(L10n.resumeOffsetDescription) + } + + ButtonSection() + + SliderSection() + + SubtitleSection() + + TimestampSection() + } + .navigationTitle(L10n.videoPlayer) + } +} diff --git a/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift b/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift new file mode 100644 index 00000000..adda02f9 --- /dev/null +++ b/Swiftfin/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift @@ -0,0 +1,63 @@ +// +// 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 JellyfinAPI +import Mantis +import SwiftUI + +// TODO: cleanup alongside `ItemPhotoCropView` + +struct UserProfileImageCropView: View { + + // MARK: - State, Observed, & Environment Objects + + @Router + private var router + + @ObservedObject + var viewModel: UserProfileImageViewModel + + // MARK: - Image Variable + + let image: UIImage + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Body + + var body: some View { + PhotoCropView( + isSaving: viewModel.state == .uploading, + image: image, + cropShape: .square, + presetRatio: .alwaysUsingOnePresetFixedRatio(ratio: 1) + ) { + viewModel.send(.upload($0)) + } onCancel: { + router.dismiss() + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .interactiveDismissDisabled(viewModel.state == .uploading) + .navigationBarBackButtonHidden(viewModel.state == .uploading) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .deleted: + break + case .uploaded: + router.dismiss() + } + } + .errorMessage($error) + } +} diff --git a/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift new file mode 100644 index 00000000..b8f79dc8 --- /dev/null +++ b/Swiftfin/Views/UserProfileImagePicker/UserProfileImagePickerView.swift @@ -0,0 +1,30 @@ +// +// 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 UserProfileImagePickerView: View { + + // MARK: - Observed, & Environment Objects + + @Router + private var router + + @StateObject + var viewModel: UserProfileImageViewModel + + // MARK: - Body + + var body: some View { + PhotoPickerView { + router.route(to: .userProfileImageCrop(viewModel: viewModel, image: $0)) + } onCancel: { + router.dismiss() + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/Gestures/PanHandlingAction.swift b/Swiftfin/Views/VideoPlayerContainerView/Gestures/PanHandlingAction.swift new file mode 100644 index 00000000..91a08d62 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/Gestures/PanHandlingAction.swift @@ -0,0 +1,73 @@ +// +// 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 + +protocol _PanHandlingAction { + associatedtype Value: Comparable & AdditiveArithmetic + + typealias OnChangeAction = ( + _ startState: _PanStartHandlingState, + _ panState: _PanHandlingState, + _ containerState: VideoPlayerContainerState + ) -> Void + + var startState: _PanStartHandlingState { get set } + var startValue: (VideoPlayerContainerState) -> Value { get set } + + var onChange: OnChangeAction { get } +} + +struct _PanHandlingState { + let translation: CGPoint + let velocity: CGPoint + let location: CGPoint + let unitPoint: UnitPoint + let gestureState: UIGestureRecognizer.State +} + +struct _PanStartHandlingState { + let direction: Direction + let location: CGPoint + let startedWithOverlay: Bool + let value: Value +} + +struct PanHandlingAction: _PanHandlingAction { + + typealias OnChangeAction = ( + _ startState: _PanStartHandlingState, + _ panState: _PanHandlingState, + _ containerState: VideoPlayerContainerState + ) -> Void + + var startState: _PanStartHandlingState = .init( + direction: .all, + location: .zero, + startedWithOverlay: false, + value: .zero + ) + var startValue: (VideoPlayerContainerState) -> Value + let onChange: OnChangeAction + + init( + startValue: Value, + onChange: @escaping OnChangeAction + ) { + self.startValue = { _ in startValue } + self.onChange = onChange + } + + init( + startValue: @escaping (VideoPlayerContainerState) -> Value, + onChange: @escaping OnChangeAction + ) { + self.startValue = startValue + self.onChange = onChange + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift new file mode 100644 index 00000000..218951fb --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PanGesture.swift @@ -0,0 +1,331 @@ +// +// 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 MediaPlayer +import SwiftUI +import UIKit + +extension VideoPlayer.UIVideoPlayerContainerViewController { + + func handlePanGesture( + translation: CGPoint, + velocity: CGPoint, + location: CGPoint, + unitPoint: UnitPoint, + state: UIGestureRecognizer.State + ) { + guard checkGestureLock() else { return } + + if state == .began { + containerState.timer.stop() + } + + if state == .ended { + containerState.timer.poke() + } + + if containerState.isPresentingSupplement { + handleSupplementPanAction( + translation: translation, + velocity: velocity.y, + location: location, + state: state + ) + + return + } + + let direction: Direction = { + // Prioritize horizontal detection just a bit more + if velocity.y.magnitude < velocity.x.magnitude + 20 { + return velocity.x > 0 ? .right : .left + } + return velocity.y > 0 ? .down : .up + }() + + let handlingState: _PanHandlingState = .init( + translation: translation, + velocity: velocity, + location: location, + unitPoint: unitPoint, + gestureState: state + ) + + if Defaults[.VideoPlayer.Gesture.horizontalSwipeAction] != .none, direction.isHorizontal { + if !containerState.didSwipe, + max(velocity.x.magnitude, velocity.y.magnitude) >= 1200, + max(translation.x.magnitude, translation.y.magnitude) >= 80 + { + handleSwipeAction(direction: direction) + containerState.didSwipe = true + } + + if state == .ended { + containerState.didSwipe = false + } + + return + } + + if state == .began { + let newAction = makePanHandlingAction( + direction: direction, + location: location, + unitPoint: unitPoint + ) + + containerState.panHandlingAction = newAction + } + + if let currentAction = containerState.panHandlingAction { + unpackAndHandlePan( + handlingState: handlingState, + action: currentAction + ) + } + + guard state != .ended else { + containerState.panHandlingAction = nil + return + } + } + + private func unpackAndHandlePan( + handlingState: _PanHandlingState, + action: Handler + ) { + action.onChange( + action.startState, + handlingState, + containerState + ) + } + + private func makePanHandlingAction( + direction: Direction, + location: CGPoint, + unitPoint: UnitPoint + ) -> any _PanHandlingAction { + let newAction: any _PanHandlingAction = { + if direction.isVertical { + if unitPoint.x < 0.5 { + panActionForGestureAction( + for: Defaults[.VideoPlayer.Gesture.verticalPanLeftAction] + ) + } else { + panActionForGestureAction( + for: Defaults[.VideoPlayer.Gesture.verticalPanRightAction] + ) + } + } else { + panActionForGestureAction( + for: Defaults[.VideoPlayer.Gesture.horizontalPanAction] + ) + } + }() + + func unpackAndSetStartState( + action: Handler + ) -> Handler { + var action = action + action.startState = _PanStartHandlingState( + direction: direction, + location: location, + startedWithOverlay: containerState.isPresentingOverlay, + value: action.startValue(containerState) + ) + return action + } + + return unpackAndSetStartState(action: newAction) + } + + private func panActionForGestureAction(for gestureAction: PanGestureAction) -> any _PanHandlingAction { + let isLiveStream = containerState.manager?.item.isLiveStream == true + + switch (gestureAction, isLiveStream) { + case (.none, _), (.scrub, true), (.slowScrub, true): + return Self.SupplementPanHandlingAction + case (.brightness, _): + return Self.BrightnessPanHandlingAction + case (.scrub, false): + return Self.ScrubPanHandlingAction() + case (.slowScrub, false): + return Self.ScrubPanHandlingAction(damping: 0.1) + case (.volume, _): + return Self.VolumePanHandlingAction + } + } + + private func handleSwipeAction(direction: Direction) { + guard containerState.manager?.item.isLiveStream == false else { return } + let jumpProgressObserver = containerState.jumpProgressObserver + + if direction == .left { + let interval = Defaults[.VideoPlayer.jumpBackwardInterval] + containerState.manager?.proxy?.jumpBackward(interval.rawValue) + jumpProgressObserver.jumpBackward() + + containerState.toastProxy.present( + Text( + interval.rawValue * jumpProgressObserver.jumps, + format: .minuteSecondsNarrow + ), + systemName: "gobackward" + ) + } else if direction == .right { + let interval = Defaults[.VideoPlayer.jumpForwardInterval] + containerState.manager?.proxy?.jumpForward(interval.rawValue) + jumpProgressObserver.jumpForward() + + containerState.toastProxy.present( + Text( + interval.rawValue * jumpProgressObserver.jumps, + format: .minuteSecondsNarrow + ), + systemName: "goforward" + ) + } + } +} + +// MARK: - Pan actions + +extension VideoPlayer.UIVideoPlayerContainerViewController { + + // MARK: - Brightness + + private static var BrightnessPanHandlingAction: PanHandlingAction { + PanHandlingAction( + startValue: UIScreen.main.brightness + ) { startState, handlingState, containerState in + guard handlingState.gestureState != .ended else { return } + + let translation: CGFloat = { + if startState.direction.isHorizontal { + handlingState.translation.x + } else { + -handlingState.translation.y + } + }() + + let newBrightness = clamp( + startState.value + CGFloat(translation / 300), + min: 0, + max: 1 + ) + + containerState.toastProxy.present( + Text(newBrightness, format: .percent.precision(.fractionLength(0))), + systemName: "sun.max.fill" + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + UIScreen.main.brightness = newBrightness + } + } + } + + // MARK: - Scrub + + private static func ScrubPanHandlingAction( + damping: CGFloat = 1 + ) -> PanHandlingAction { + PanHandlingAction( + startValue: { containerState in + containerState.scrubbedSeconds.value + } + ) { startState, handlingState, containerState in + if handlingState.gestureState == .ended { + containerState.isScrubbing = false + + if !startState.startedWithOverlay { + containerState.isPresentingOverlay = false + } + return + } + + guard let runtime = containerState.manager?.item.runtime else { return } + + let translation: CGFloat = { + if startState.direction.isHorizontal { + handlingState.translation.x + } else { + -handlingState.translation.y + } + }() + let totalSize: CGFloat = { + if startState.direction.isHorizontal { + handlingState.location.x / handlingState.unitPoint.x + } else { + handlingState.location.y / handlingState.unitPoint.y + } + }() + + containerState.isScrubbing = true + containerState.isPresentingOverlay = true + + let newSeconds = clamp( + startState.value.seconds + (translation / totalSize) * runtime.seconds * damping, + min: 0, + max: runtime.seconds + ) + + let newSecondsDuration = Duration.seconds(newSeconds) + + containerState.scrubbedSeconds.value = newSecondsDuration + } + } + + // MARK: - Supplement + + private static var SupplementPanHandlingAction: PanHandlingAction { + PanHandlingAction( + startValue: 0 + ) { _, handlingState, containerState in + containerState.containerView?.handleSupplementPanAction( + translation: handlingState.translation, + velocity: handlingState.velocity.y, + location: handlingState.location, + state: handlingState.gestureState + ) + } + } + + // MARK: - Volume + + private static var VolumePanHandlingAction: PanHandlingAction { + PanHandlingAction( + startValue: AVAudioSession.sharedInstance().outputVolume + ) { startState, handlingState, _ in + guard handlingState.gestureState != .ended else { return } + + guard let slider = MPVolumeView() + .subviews + .first(where: { $0 is UISlider }) as? UISlider else { return } + let translation: CGFloat = { + if startState.direction.isHorizontal { + return handlingState.translation.x + } else { + return -handlingState.translation.y + } + }() + + let newVolume = clamp( + startState.value + Float(translation / 300), + min: 0, + max: 1 + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + slider.value = newVolume + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PinchGesture.swift b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PinchGesture.swift new file mode 100644 index 00000000..64b08078 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+PinchGesture.swift @@ -0,0 +1,35 @@ +// +// 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 VideoPlayer.UIVideoPlayerContainerViewController { + + func handlePinchGesture( + scale: CGFloat, + velocity: CGFloat, + state: UIGestureRecognizer.State + ) { + guard checkGestureLock() else { return } + guard !containerState.isPresentingSupplement, state != .ended else { return } + guard state != .ended else { return } + + let action = Defaults[.VideoPlayer.Gesture.pinchGesture] + + switch action { + case .none: () + case .aspectFill: + if scale > 1, !containerState.isAspectFilled { + containerState.isAspectFilled = true + } else if scale < 1, containerState.isAspectFilled { + containerState.isAspectFilled = false + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+TapGesture.swift b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+TapGesture.swift new file mode 100644 index 00000000..cca41f15 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+TapGesture.swift @@ -0,0 +1,227 @@ +// +// 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 + +// TODO: multitap refinements +// - don't increment jump progress if hit ends +// - verify if ending media + +extension VideoPlayer.UIVideoPlayerContainerViewController { + + func checkGestureLock() -> Bool { + if containerState.isGestureLocked { + containerState.toastProxy.present( + L10n.gesturesLocked, + systemName: VideoPlayerActionButton.gestureLock.systemImage + ) + return false + } + return true + } + + func handleTapGestureInSupplement( + location: CGPoint, + unitPoint: UnitPoint, + count: Int + ) { + guard !containerState.isPresentingSupplement else { return } + + handleTapGesture( + location: location, + unitPoint: unitPoint, + count: count + ) + } + + func handleTapGesture( + location: CGPoint, + unitPoint: UnitPoint, + count: Int + ) { + if count == 1 { + guard checkGestureLock() else { return } + + handleSingleTapGesture( + location: location, + unitPoint: unitPoint + ) + } + + if count == 2 { + handleDoubleTouchGesture( + location: location, + unitPoint: unitPoint + ) + } + } + + private func handleSingleTapGesture( + location: CGPoint, + unitPoint: UnitPoint + ) { + if containerState.isPresentingSupplement { + if containerState.isCompact { + containerState.isPresentingPlaybackControls.toggle() + } else { + containerState.select(supplement: nil) + } + } else { + containerState.isPresentingOverlay.toggle() + } + + let action = Defaults[.VideoPlayer.Gesture.multiTapGesture] + let jumpProgressObserver = containerState.jumpProgressObserver + let width = location.x / unitPoint.x + + switch action { + case .none: () + case .jump: + guard containerState.manager?.item.isLiveStream == false else { return } + + if let lastTapLocation = containerState.lastTapLocation { + + let (isSameSide, isLeftSide) = pointsAreSameSide( + lastTapLocation, + location, + width: width, + midPadding: containerState.isCompact ? 20 : 50 + ) + + if isSameSide { + + containerState.isPresentingOverlay = false + + if isLeftSide { + let interval = Defaults[.VideoPlayer.jumpBackwardInterval] + containerState.manager?.proxy?.jumpBackward(interval.rawValue) + + containerState.toastProxy.present( + Text( + interval.rawValue * (jumpProgressObserver.jumps), + format: .minuteSecondsNarrow + ), + systemName: "gobackward" + ) + } else { + let interval = Defaults[.VideoPlayer.jumpForwardInterval] + containerState.manager?.proxy?.jumpForward(interval.rawValue) + + containerState.toastProxy.present( + Text( + interval.rawValue * (jumpProgressObserver.jumps), + format: .minuteSecondsNarrow + ), + systemName: "goforward" + ) + } + } + } + } + + let side = side( + of: location, + width: width, + midPadding: containerState.isCompact ? 20 : 50 + ) + containerState.lastTapLocation = location + + if side { + jumpProgressObserver.jumpBackward(interval: 0.35) + } else { + jumpProgressObserver.jumpForward(interval: 0.35) + } + } + + private func side( + of point: CGPoint, + width: CGFloat, + midPadding: CGFloat = 50 + ) -> Bool { + let midX = width / 2 + let leftSide = midX - midPadding + + return point.x < leftSide + } + + private func pointsAreSameSide( + _ p1: CGPoint, + _ p2: CGPoint, + width: CGFloat, + midPadding: CGFloat = 50 + ) -> (isSameSide: Bool, isLeftSide: Bool) { + let p1Side = side(of: p1, width: width, midPadding: midPadding) + let p2Side = side(of: p2, width: width, midPadding: midPadding) + + return (p1Side == p2Side, p1Side) + } + + private func handleDoubleTouchGesture( + location: CGPoint, + unitPoint: UnitPoint + ) { + let action = Defaults[.VideoPlayer.Gesture.doubleTouchGesture] + + switch action { + case .none: () + case .aspectFill: + guard checkGestureLock() else { return } + containerState.isAspectFilled.toggle() + case .gestureLock: + if containerState.isGestureLocked { + containerState.isGestureLocked = false + + containerState.toastProxy.present( + L10n.gesturesUnlocked, + systemName: VideoPlayerActionButton.gestureLock.secondarySystemImage + ) + } else { + containerState.isGestureLocked = true + + containerState.toastProxy.present( + L10n.gesturesLocked, + systemName: VideoPlayerActionButton.gestureLock.systemImage + ) + } + case .pausePlay: + guard checkGestureLock() else { return } + containerState.manager?.togglePlayPause() + } + } + + func handleLongPressGesture( + location: CGPoint, + unitPoint: UnitPoint, + state: UILongPressGestureRecognizer.State + ) { + guard state != .ended else { return } + + let action = Defaults[.VideoPlayer.Gesture.longPressAction] + + switch action { + case .none: () + case .gestureLock: + if containerState.isGestureLocked { + containerState.isGestureLocked = false + + containerState.toastProxy.present( + L10n.gesturesUnlocked, + systemName: VideoPlayerActionButton.gestureLock.secondarySystemImage + ) + } else { + containerState.isGestureLocked = true + + containerState.toastProxy.present( + L10n.gesturesLocked, + systemName: VideoPlayerActionButton.gestureLock.systemImage + ) + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/CurrentSecondTick.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/CurrentSecondTick.swift new file mode 100644 index 00000000..327b95d0 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/CurrentSecondTick.swift @@ -0,0 +1,34 @@ +// +// 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 VideoPlayer.PlaybackControls { + + // TODO: make option + struct CurrentSecondTick: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var activeSeconds: Duration = .zero + + var body: some View { + if let runtime = manager.item.runtime, runtime > .zero { + GeometryReader { proxy in + Color.white + .frame(width: 1.5) + .offset(x: proxy.size.width * (activeSeconds / runtime) - 0.75) + .frame(maxWidth: .infinity, alignment: .leading) + } + .assign(manager.secondsBox.$value, to: $activeSeconds) + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift new file mode 100644 index 00000000..20bb907a --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift @@ -0,0 +1,141 @@ +// +// 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 + +// TODO: ensure changes on playback item change + +extension VideoPlayer.PlaybackControls.NavigationBar { + + struct ActionButtons: View { + + @Default(.VideoPlayer.barActionButtons) + private var rawBarActionButtons + @Default(.VideoPlayer.menuActionButtons) + private var rawMenuActionButtons + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + private func filteredActionButtons(_ rawButtons: [VideoPlayerActionButton]) -> [VideoPlayerActionButton] { + var filteredButtons = rawButtons + + if manager.playbackItem?.audioStreams.isEmpty == true { + filteredButtons.removeAll { $0 == .audio } + } + + if manager.playbackItem?.subtitleStreams.isEmpty == true { + filteredButtons.removeAll { $0 == .subtitles } + } + + if manager.queue == nil { + filteredButtons.removeAll { $0 == .autoPlay } + filteredButtons.removeAll { $0 == .playNextItem } + filteredButtons.removeAll { $0 == .playPreviousItem } + } + + if manager.item.isLiveStream { + filteredButtons.removeAll { $0 == .audio } + filteredButtons.removeAll { $0 == .autoPlay } + filteredButtons.removeAll { $0 == .playbackSpeed } +// filteredButtons.removeAll { $0 == .playbackQuality } + filteredButtons.removeAll { $0 == .subtitles } + } + + return filteredButtons + } + + private var barActionButtons: [VideoPlayerActionButton] { + filteredActionButtons(rawBarActionButtons) + } + + private var menuActionButtons: [VideoPlayerActionButton] { + filteredActionButtons(rawMenuActionButtons) + } + + @ViewBuilder + private func view(for button: VideoPlayerActionButton) -> some View { + switch button { + case .aspectFill: + AspectFill() + case .audio: + Audio() + case .autoPlay: + AutoPlay() + case .gestureLock: + GestureLock() + case .playbackSpeed: + PlaybackRateMenu() +// case .playbackQuality: +// PlaybackQuality() + case .playNextItem: + PlayNextItem() + case .playPreviousItem: + PlayPreviousItem() + case .subtitles: + Subtitles() + } + } + + @ViewBuilder + private var compactView: some View { + Menu( + L10n.menu, + systemImage: "ellipsis.circle" + ) { + ForEach( + barActionButtons, + content: view(for:) + ) + .environment(\.isInMenu, true) + + Divider() + + ForEach( + menuActionButtons, + content: view(for:) + ) + .environment(\.isInMenu, true) + } + } + + @ViewBuilder + private var regularView: some View { + HStack(spacing: 0) { + ForEach( + barActionButtons, + content: view(for:) + ) + + if menuActionButtons.isNotEmpty { + Menu( + L10n.menu, + systemImage: "ellipsis.circle" + ) { + ForEach( + menuActionButtons, + content: view(for:) + ) + .environment(\.isInMenu, true) + } + } + } + } + + var body: some View { + if containerState.isCompact { + compactView + } else { + regularView + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AspectFillActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AspectFillActionButton.swift new file mode 100644 index 00000000..49b54096 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AspectFillActionButton.swift @@ -0,0 +1,42 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct AspectFill: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + private var isAspectFilled: Bool { + get { containerState.isAspectFilled } + nonmutating set { containerState.isAspectFilled = newValue } + } + + private var systemImage: String { + if isAspectFilled { + VideoPlayerActionButton.aspectFill.secondarySystemImage + } else { + VideoPlayerActionButton.aspectFill.systemImage + } + } + + var body: some View { + Button( + L10n.aspectFill, + systemImage: systemImage + ) { + isAspectFilled.toggle() + } + .videoPlayerActionButtonTransition() + .id(isAspectFilled) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift new file mode 100644 index 00000000..da9baec1 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift @@ -0,0 +1,65 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct Audio: View { + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var selectedAudioStreamIndex: Int? + + private var systemImage: String { + if selectedAudioStreamIndex == nil { + VideoPlayerActionButton.audio.secondarySystemImage + } else { + VideoPlayerActionButton.audio.systemImage + } + } + + @ViewBuilder + private func content(playbackItem: MediaPlayerItem) -> some View { + Picker(L10n.audio, selection: $selectedAudioStreamIndex) { + ForEach(playbackItem.audioStreams, id: \.index) { stream in + Text(stream.displayTitle ?? L10n.unknown) + .tag(stream.index as Int?) + } + } + } + + var body: some View { + if let playbackItem = manager.playbackItem { + Menu( + L10n.audio, + systemImage: systemImage + ) { + if isInMenu { + content(playbackItem: playbackItem) + } else { + Section(L10n.audio) { + content(playbackItem: playbackItem) + } + } + } + .videoPlayerActionButtonTransition() + .assign(playbackItem.$selectedAudioStreamIndex, to: $selectedAudioStreamIndex) + .backport + .onChange(of: selectedAudioStreamIndex) { _, newValue in + playbackItem.selectedAudioStreamIndex = newValue + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift new file mode 100644 index 00000000..e899fd00 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift @@ -0,0 +1,60 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct AutoPlay: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var isAutoPlayEnabled + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + @Toaster + private var toaster + + private var systemImage: String { + if isAutoPlayEnabled { + VideoPlayerActionButton.autoPlay.systemImage + } else { + VideoPlayerActionButton.autoPlay.secondarySystemImage + } + } + + var body: some View { + Button { + isAutoPlayEnabled.toggle() + + if isAutoPlayEnabled { + toaster.present("Auto Play on", systemName: "play.circle.fill") + } else { + toaster.present("Auto Play off", systemName: "stop.circle") + } + } label: { + Label( + L10n.autoPlay, + systemImage: systemImage + ) + + if isInMenu { + Text(isAutoPlayEnabled ? "On" : "Off") + } + } + .videoPlayerActionButtonTransition() + .id(isAutoPlayEnabled) + .disabled(manager.queue == nil) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/GestureLockActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/GestureLockActionButton.swift new file mode 100644 index 00000000..faad4bb0 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/GestureLockActionButton.swift @@ -0,0 +1,33 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct GestureLock: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + private var isGestureLocked: Bool { + get { containerState.isGestureLocked } + nonmutating set { containerState.isGestureLocked = newValue } + } + + var body: some View { + Button( + L10n.gestureLock, + systemImage: VideoPlayerActionButton.gestureLock.systemImage + ) { + isGestureLocked.toggle() + } + .videoPlayerActionButtonTransition() + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayNextItemActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayNextItemActionButton.swift new file mode 100644 index 00000000..75414ae1 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayNextItemActionButton.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlayNextItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + if let queue = manager.queue { + _PlayNextItem(queue: queue) + } + } + } + + private struct _PlayNextItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + var queue: AnyMediaPlayerQueue + + var body: some View { + Button( + L10n.playNextItem, + systemImage: VideoPlayerActionButton.playNextItem.systemImage + ) { + guard let nextItem = queue.nextItem else { return } + manager.playNewItem(provider: nextItem) + } + .disabled(queue.nextItem == nil) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayPreviousItemActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayPreviousItemActionButton.swift new file mode 100644 index 00000000..df179f88 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlayPreviousItemActionButton.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlayPreviousItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + if let queue = manager.queue { + _PlayPreviousItem(queue: queue) + } + } + } + + private struct _PlayPreviousItem: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + + @ObservedObject + var queue: AnyMediaPlayerQueue + + var body: some View { + Button( + L10n.playPreviousItem, + systemImage: VideoPlayerActionButton.playPreviousItem.systemImage + ) { + guard let previousItem = queue.previousItem else { return } + manager.playNewItem(provider: previousItem) + } + .disabled(queue.previousItem == nil) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift new file mode 100644 index 00000000..d569d39e --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift @@ -0,0 +1,85 @@ +// +// 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 + +// TODO: compatibility picker +// TODO: auto test setting +// TODO: change to general playback settings? +// - versions +// TODO: have queue consider value to carry setting +// TODO: reuse-provider instead of making a new one? +// TODO: don't present for offline/live items +// - value on media player item +// TODO: filter to sensible subset + +extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlaybackQuality: View { + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + private func makeProvider(with bitrate: PlaybackBitrate, for playbackItem: MediaPlayerItem) -> MediaPlayerItemProvider { + var adjustedBaseItem = playbackItem.baseItem + adjustedBaseItem.userData?.playbackPositionTicks = manager.seconds.ticks + let mediaSource = playbackItem.mediaSource + + return MediaPlayerItemProvider( + item: adjustedBaseItem, + function: { baseItem in + try await MediaPlayerItem.build( + for: baseItem, + mediaSource: mediaSource, + requestedBitrate: bitrate + ) + } + ) + } + + // TODO: transition to Picker + // - need local State value + @ViewBuilder + private func content(playbackItem: MediaPlayerItem) -> some View { + ForEach(PlaybackBitrate.allCases, id: \.rawValue) { bitrate in + Button { + guard playbackItem.requestedBitrate != bitrate else { return } + let provider = makeProvider(with: bitrate, for: playbackItem) + manager.playNewItem(provider: provider) + } label: { + if playbackItem.requestedBitrate == bitrate { + Label(bitrate.displayTitle, systemImage: "checkmark") + } else { + Text(bitrate.displayTitle) + } + } + } + } + + var body: some View { + if let playbackItem = manager.playbackItem { + Menu( + L10n.playbackQuality, + systemImage: "" +// systemImage: VideoPlayerActionButton.playbackQuality.systemImage + ) { + if isInMenu { + content(playbackItem: playbackItem) + } else { + Section(L10n.playbackQuality) { + content(playbackItem: playbackItem) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackRateActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackRateActionButton.swift new file mode 100644 index 00000000..d0f16522 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackRateActionButton.swift @@ -0,0 +1,47 @@ +// +// 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 + +// TODO: set through proxy + +extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct PlaybackRateMenu: View { + + @Default(.VideoPlayer.Playback.rates) + private var rates: [Float] + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + var body: some View { + Menu( + L10n.playbackSpeed, + systemImage: VideoPlayerActionButton.playbackSpeed.systemImage + ) { + Picker(L10n.playbackSpeed, selection: $manager.rate) { + ForEach(rates, id: \.self) { rate in + Text(rate, format: .playbackRate) + .tag(rate) + } + + if !rates.contains(manager.rate) { + Divider() + + Text(manager.rate, format: .playbackRate) + .tag(manager.rate) + } + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift new file mode 100644 index 00000000..844c1a25 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift @@ -0,0 +1,65 @@ +// +// 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 VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { + + struct Subtitles: View { + + @Environment(\.isInMenu) + private var isInMenu + + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var selectedSubtitleStreamIndex: Int? + + private var systemImage: String { + if selectedSubtitleStreamIndex == nil { + VideoPlayerActionButton.subtitles.secondarySystemImage + } else { + VideoPlayerActionButton.subtitles.systemImage + } + } + + @ViewBuilder + private func content(playbackItem: MediaPlayerItem) -> some View { + Picker(L10n.subtitles, selection: $selectedSubtitleStreamIndex) { + ForEach(playbackItem.subtitleStreams.prepending(.none), id: \.index) { stream in + Text(stream.displayTitle ?? L10n.unknown) + .tag(stream.index as Int?) + } + } + } + + var body: some View { + if let playbackItem = manager.playbackItem { + Menu( + L10n.subtitles, + systemImage: systemImage + ) { + if isInMenu { + content(playbackItem: playbackItem) + } else { + Section(L10n.subtitles) { + content(playbackItem: playbackItem) + } + } + } + .videoPlayerActionButtonTransition() + .assign(playbackItem.$selectedSubtitleStreamIndex, to: $selectedSubtitleStreamIndex) + .backport + .onChange(of: selectedSubtitleStreamIndex) { _, newValue in + playbackItem.selectedSubtitleStreamIndex = newValue + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/NavigationBar.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/NavigationBar.swift new file mode 100644 index 00000000..459a2188 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/NavigationBar.swift @@ -0,0 +1,113 @@ +// +// 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 + +// TODO: determine smaller font size for title + +extension VideoPlayer.PlaybackControls { + + struct NavigationBar: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + @Router + private var router + + private func onPressed(isPressed: Bool) { + if isPressed { + containerState.timer.stop() + } else { + containerState.timer.poke() + } + } + + var body: some View { + HStack(alignment: .center) { + Button { + if containerState.isPresentingSupplement { + containerState.select(supplement: nil) + } else { + manager.stop() + router.dismiss() + } + } label: { + AlternateLayoutView { + Image(systemName: "xmark") + } content: { + Label( + L10n.close, + systemImage: containerState.isPresentingSupplement ? "chevron.down" : "xmark" + ) + } + .contentShape(Rectangle()) + } + + TitleView(item: manager.item) + .frame(maxWidth: .infinity, alignment: .leading) + + ActionButtons() + } + .background { + EmptyHitTestView() + } + .font(.system(size: 24, weight: .semibold)) + .buttonStyle(OverlayButtonStyle(onPressed: onPressed)) + } + } +} + +extension VideoPlayer.PlaybackControls.NavigationBar { + + struct TitleView: View { + + @State + private var subtitleContentSize: CGSize = .zero + + let item: BaseItemDto + + private var _titleSubtitle: (title: String, subtitle: String?) { + if item.type == .episode { + if let parentTitle = item.parentTitle { + return (title: parentTitle, subtitle: item.seasonEpisodeLabel) + } + } + + return (title: item.displayTitle, subtitle: nil) + } + + @ViewBuilder + private func _subtitle(_ subtitle: String) -> some View { + Text(subtitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.white) + .trackingSize($subtitleContentSize) + } + + var body: some View { + let titleSubtitle = self._titleSubtitle + + Text(titleSubtitle.title) + .fontWeight(.semibold) + .lineLimit(1) + .frame(minWidth: max(50, subtitleContentSize.width)) + .overlay(alignment: .bottomLeading) { + if let subtitle = titleSubtitle.subtitle { + _subtitle(subtitle) + .lineLimit(1) + .offset(y: subtitleContentSize.height) + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/OverlayButtonStyle.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/OverlayButtonStyle.swift new file mode 100644 index 00000000..9b3b40f0 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/OverlayButtonStyle.swift @@ -0,0 +1,41 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct OverlayButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) + private var isEnabled + + let onPressed: (Bool) -> Void + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.primary) : AnyShapeStyle(Color.gray)) + .labelStyle(.iconOnly) + .contentShape(Rectangle()) + .scaleEffect( + configuration.isPressed ? 0.8 : 1 + ) + .animation(.bouncy(duration: 0.25, extraBounce: 0.25), value: configuration.isPressed) + .padding(4) + .animation(nil, value: configuration.isPressed) + .background { + Circle() + .foregroundStyle(Color.white.opacity(configuration.isPressed ? 0.25 : 0)) + .scaleEffect(configuration.isPressed ? 1 : 0.9) + } + .animation(.linear(duration: 0.1).delay(configuration.isPressed ? 0.2 : 0), value: configuration.isPressed) + .padding(4) + .onChange(of: configuration.isPressed, perform: onPressed) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackButtons.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackButtons.swift new file mode 100644 index 00000000..29dd9255 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackButtons.swift @@ -0,0 +1,116 @@ +// +// 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 + +// TODO: adjust button sizes/padding on compact/regular? +// TODO: jump rotation symbol effects + +extension VideoPlayer.PlaybackControls { + + struct PlaybackButtons: View { + + @Default(.VideoPlayer.jumpBackwardInterval) + private var jumpBackwardInterval + @Default(.VideoPlayer.jumpForwardInterval) + private var jumpForwardInterval + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + private func onPressed(isPressed: Bool) { + if isPressed { + containerState.timer.stop() + } else { + containerState.timer.poke() + } + } + + private var shouldShowJumpButtons: Bool { + !manager.item.isLiveStream + } + + @ViewBuilder + private var playButton: some View { + Button { + switch manager.playbackRequestStatus { + case .playing: + manager.setPlaybackRequestStatus(status: .paused) + case .paused: + manager.setPlaybackRequestStatus(status: .playing) + } + } label: { + Group { + switch manager.playbackRequestStatus { + case .playing: + Label("Pause", systemImage: "pause.fill") + case .paused: + Label(L10n.play, systemImage: "play.fill") + } + } + .transition(.opacity.combined(with: .scale).animation(.bouncy(duration: 0.7, extraBounce: 0.2))) + .font(.system(size: 36, weight: .bold, design: .default)) + .contentShape(Rectangle()) + .labelStyle(.iconOnly) + .padding(20) + } + } + + @ViewBuilder + private var jumpForwardButton: some View { + Button { + manager.proxy?.jumpForward(jumpForwardInterval.rawValue) + } label: { + Label( + "\(jumpForwardInterval.rawValue, format: Duration.UnitsFormatStyle(allowedUnits: [.seconds], width: .narrow))", + systemImage: jumpForwardInterval.forwardSystemImage + ) + .labelStyle(.iconOnly) + .font(.system(size: 32, weight: .regular, design: .default)) + .padding(10) + } + .foregroundStyle(.primary) + } + + @ViewBuilder + private var jumpBackwardButton: some View { + Button { + manager.proxy?.jumpBackward(jumpBackwardInterval.rawValue) + } label: { + Label( + "\(jumpBackwardInterval.rawValue, format: Duration.UnitsFormatStyle(allowedUnits: [.seconds], width: .narrow))", + systemImage: jumpBackwardInterval.backwardSystemImage + ) + .labelStyle(.iconOnly) + .font(.system(size: 32, weight: .regular, design: .default)) + .padding(10) + } + .foregroundStyle(.primary) + } + + var body: some View { + HStack(spacing: 0) { + if shouldShowJumpButtons { + jumpBackwardButton + } + + playButton + .frame(minWidth: 50, maxWidth: 150) + + if shouldShowJumpButtons { + jumpForwardButton + } + } + .buttonStyle(OverlayButtonStyle(onPressed: onPressed)) + .padding(.horizontal, 50) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/ChapterTrackMask.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/ChapterTrackMask.swift new file mode 100644 index 00000000..fe621bd3 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/ChapterTrackMask.swift @@ -0,0 +1,45 @@ +// +// 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 VideoPlayer.PlaybackControls.PlaybackProgress { + + struct ChapterTrackMask: View { + + let chapters: [ChapterInfo.FullInfo] + let runtime: Duration + + private var unitPoints: [Double] { + chapters.map { chapter in + guard let startSeconds = chapter.chapterInfo.startSeconds, + startSeconds < runtime + else { + return 0 + } + + return startSeconds / runtime + } + } + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + ForEach(unitPoints, id: \.self) { unitPoint in + if unitPoint > 0 { + Color.black + .frame(width: 1.5) + .offset(x: proxy.size.width * unitPoint - 0.75) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PlaybackProgress.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PlaybackProgress.swift new file mode 100644 index 00000000..dae018a6 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PlaybackProgress.swift @@ -0,0 +1,186 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: enabled/disabled state +// TODO: scrubbing snapping behaviors +// - chapter boundaries +// - current running time +// TODO: show chapter title under preview image +// - have max width, on separate offset track + +extension VideoPlayer.PlaybackControls { + + struct PlaybackProgress: View { + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var scrubbedSecondsBox: PublishedBox + + @State + private var currentTranslation: CGPoint = .zero + + @State + private var sliderSize: CGSize = .zero + + private var isScrubbing: Bool { + get { + containerState.isScrubbing + } + nonmutating set { + containerState.isScrubbing = newValue + } + } + + private var isSlowScrubbing: Bool { + isScrubbing && (currentTranslation.y >= 60) + } + + private var previewXOffset: CGFloat { + let videoWidth = 85 * videoSizeAspectRatio + let p = (sliderSize.width * scrubbedProgress) - (videoWidth / 2) + return clamp(p, min: 0, max: sliderSize.width - videoWidth) + } + + private var progress: Double { + scrubbedSeconds / (manager.item.runtime ?? .seconds(1)) + } + + private var scrubbedProgress: Double { + guard let runtime = manager.item.runtime, runtime > .zero else { return 0 } + return scrubbedSeconds / runtime + } + + private var scrubbedSeconds: Duration { + scrubbedSecondsBox.value + } + + private var videoSizeAspectRatio: CGFloat { + guard let videoPlayerProxy = manager.proxy as? any VideoMediaPlayerProxy else { + return 1.77 + } + + return clamp(videoPlayerProxy.videoSize.value.aspectRatio, min: 0.25, max: 4) + } + + @ViewBuilder + private var liveIndicator: some View { + Text("Live") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background { + Capsule() + .fill(Color.gray) + } + } + + @ViewBuilder + private var slowScrubbingIndicator: some View { + HStack { + Image(systemName: "backward.fill") + Text("Slow Scrubbing") + Image(systemName: "forward.fill") + } + .font(.caption) + } + + @ViewBuilder + private var capsuleSlider: some View { + AlternateLayoutView { + EmptyHitTestView() + .frame(height: 10) + .trackingSize($sliderSize) + } content: { + // Use scale effect, slider doesn't respond well to horizontal frame changes + let xScale = max(1, sliderSize.width / (sliderSize.width - EdgeInsets.edgePadding * 2)) + + CapsuleSlider( + value: $scrubbedSecondsBox.value.map( + getter: { $0.seconds }, + setter: { .seconds($0) } + ), + total: max(1, (manager.item.runtime ?? .zero).seconds), + translation: $currentTranslation, + valueDamping: isSlowScrubbing ? 0.1 : 1 + ) + .gesturePadding(30) + .onEditingChanged { newValue in + isScrubbing = newValue + } + .if(chapterSlider) { view in + view.ifLet(manager.item.fullChapterInfo) { view, chapters in + if chapters.isEmpty { + view + } else { + view.inverseMask { ChapterTrackMask(chapters: chapters, runtime: manager.item.runtime ?? .zero) } + } + } + } + .frame(maxWidth: sliderSize != .zero ? sliderSize.width - EdgeInsets.edgePadding * 2 : .infinity) + .scaleEffect(x: isScrubbing ? xScale : 1, y: 1, anchor: .center) + .frame(height: isScrubbing ? 20 : 10) + .foregroundStyle(manager.state == .loadingItem ? .gray : .primary) + } + .animation(.linear(duration: 0.05), value: scrubbedSeconds) + .frame(height: 10) + .disabled(manager.state == .loadingItem) + } + + var body: some View { + VStack(spacing: 5) { + if manager.item.isLiveStream { + liveIndicator + .edgePadding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + capsuleSlider + .trackingSize($sliderSize) + + SplitTimeStamp() + .offset(y: isScrubbing ? 5 : 0) + .frame(maxWidth: isScrubbing ? nil : max(0, sliderSize.width - EdgeInsets.edgePadding * 2)) + } + } + .frame(maxWidth: .infinity) + .animation(.bouncy(duration: 0.4, extraBounce: 0.1), value: isScrubbing) + .overlay(alignment: .topLeading) { + if isScrubbing, let previewImageProvider = manager.playbackItem?.previewImageProvider { + PreviewImageView(previewImageProvider: previewImageProvider) + .aspectRatio(videoSizeAspectRatio, contentMode: .fit) + .frame(height: 85) + .posterBorder() + .cornerRadius(ratio: 1 / 30, of: \.width) + .offset(x: previewXOffset, y: -100) + } + } + .overlay(alignment: .bottom) { + if isSlowScrubbing { + slowScrubbingIndicator + .offset(y: EdgeInsets.edgePadding * 2) + .transition(.opacity.animation(.linear(duration: 0.1))) + } + } + .onChange(of: isSlowScrubbing) { _ in + guard isScrubbing else { return } + UIDevice.impact(.soft) + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PreviewImageView.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PreviewImageView.swift new file mode 100644 index 00000000..a29f4921 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/PreviewImageView.swift @@ -0,0 +1,74 @@ +// +// 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 Combine +import SwiftUI + +extension VideoPlayer.PlaybackControls { + + struct PreviewImageView: View { + + @EnvironmentObject + private var manager: MediaPlayerManager + @EnvironmentObject + private var scrubbedSecondsBox: PublishedBox + + @State + private var image: (index: Int, image: UIImage)? = nil + @State + private var currentImageTask: AnyCancellable? = nil + + let previewImageProvider: any PreviewImageProvider + + private var scrubbedSeconds: Duration { + scrubbedSecondsBox.value + } + + private func getImage(for seconds: Duration) { + currentImageTask?.cancel() + currentImageTask = nil + + let initialTask = Task(priority: .userInitiated) { + if let image = await previewImageProvider.image(for: seconds), + let index = previewImageProvider.imageIndex(for: seconds) + { + self.image = (index: index, image: image) + } else { + self.image = nil + } + } + + currentImageTask = initialTask.asAnyCancellable() + } + + var body: some View { + ZStack { + Color.black + + ZStack { + if let image { + Image(uiImage: image.image) + .resizable() + .aspectRatio(contentMode: .fit) + } + } + .id(image?.index) + } + .onAppear { + getImage(for: scrubbedSeconds) + } + .onChange(of: scrubbedSeconds) { newValue in + let newIndex = previewImageProvider.imageIndex(for: newValue) + + if newIndex != image?.index { + getImage(for: scrubbedSeconds) + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/SplitTimestamp.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/SplitTimestamp.swift new file mode 100644 index 00000000..24f5d1f5 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/PlaybackProgress/SplitTimestamp.swift @@ -0,0 +1,116 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct SplitTimeStamp: View { + + @Default(.VideoPlayer.Overlay.trailingTimestampType) + private var trailingTimestampType + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var scrubbedSecondsBox: PublishedBox + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var activeSeconds: Duration = .zero + + private var isScrubbing: Bool { + containerState.isScrubbing + } + + private var scrubbedSeconds: Duration { + scrubbedSecondsBox.value + } + + @ViewBuilder + private var leadingTimestamp: some View { + HStack(spacing: 2) { + + Text(scrubbedSeconds, format: .runtime) + + Group { + Text("/") + + Text(activeSeconds, format: .runtime) + } + .foregroundStyle(.secondary) + .isVisible(isScrubbing) + } + } + + @ViewBuilder + private var trailingTimestamp: some View { + HStack(spacing: 2) { + Group { + if let runtime = manager.item.runtime { + Text(runtime - activeSeconds, format: .runtime) + } else { + Text(verbatim: .emptyRuntime) + } + + Text("/") + } + .foregroundStyle(.secondary) + .isVisible(isScrubbing) + + if let runtime = manager.item.runtime { + switch trailingTimestampType { + case .timeLeft: + Text(.zero - (runtime - scrubbedSeconds), format: .runtime) + case .totalTime: + Text(runtime, format: .runtime) + } + } else { + Text(verbatim: .emptyRuntime) + } + } + } + + var body: some View { + HStack { + Button { + switch trailingTimestampType { + case .timeLeft: + trailingTimestampType = .totalTime + case .totalTime: + trailingTimestampType = .timeLeft + } + } label: { + leadingTimestamp + } + .foregroundStyle(.primary, .secondary) + + Spacer() + + Button { + switch trailingTimestampType { + case .timeLeft: + trailingTimestampType = .totalTime + case .totalTime: + trailingTimestampType = .timeLeft + } + } label: { + trailingTimestamp + } + .foregroundStyle(.primary, .secondary) + } + .monospacedDigit() + .font(.caption2) + .lineLimit(1) + .foregroundStyle(isScrubbing ? .primary : .secondary, .secondary) + .assign(manager.secondsBox.$value, to: $activeSeconds) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/PlaybackControls.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/PlaybackControls.swift new file mode 100644 index 00000000..1c4559fa --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/PlaybackControls.swift @@ -0,0 +1,98 @@ +// +// 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 VideoPlayer { + + struct PlaybackControls: View { + + // since this view ignores safe area, it must + // get safe area insets from parent views + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var activeIsBuffering: Bool = false + @State + private var bottomContentFrame: CGRect = .zero + + private var isPresentingOverlay: Bool { + containerState.isPresentingOverlay + } + + private var isPresentingSupplement: Bool { + containerState.isPresentingSupplement + } + + private var isScrubbing: Bool { + containerState.isScrubbing + } + + // MARK: body + + var body: some View { + ZStack { + + // MARK: - Buttons and Supplements + + VStack { + NavigationBar() + .frame(height: 50) + .isVisible(!isScrubbing && isPresentingOverlay) + .padding(.top, safeAreaInsets.top) + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + .offset(y: isPresentingOverlay ? 0 : -20) + + Spacer() + .allowsHitTesting(false) + + PlaybackProgress() + .isVisible(isPresentingOverlay && !isPresentingSupplement) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + .trackingFrame($bottomContentFrame) + .background { + if isPresentingOverlay && !isPresentingSupplement { + EmptyHitTestView() + } + } + .background(alignment: .top) { + Color.black + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 1, opacity: 0.5) + } + .isVisible(isScrubbing) + .frame(height: bottomContentFrame.height + 50 + EdgeInsets.edgePadding * 2) + } + } + + PlaybackButtons() + .isVisible(!isScrubbing && containerState.isPresentingPlaybackControls) + .offset(y: containerState.centerOffset / 2) + } + .modifier(VideoPlayer.KeyCommandsModifier()) + .animation(.linear(duration: 0.1), value: isScrubbing) + .animation(.bouncy(duration: 0.4), value: containerState.isPresentingSupplement) + .animation(.bouncy(duration: 0.25), value: containerState.isPresentingOverlay) + .onChange(of: manager.proxy?.isBuffering.value) { newValue in + activeIsBuffering = newValue ?? false + } + .disabled(manager.error != nil) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/Components/SupplementTitleButtonStyle.swift b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/Components/SupplementTitleButtonStyle.swift new file mode 100644 index 00000000..44a4f2b7 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/Components/SupplementTitleButtonStyle.swift @@ -0,0 +1,64 @@ +// +// 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 VideoPlayer.UIVideoPlayerContainerViewController.SupplementContainerView { + + struct SupplementTitleButtonStyle: PrimitiveButtonStyle { + + @Environment(\.isSelected) + private var isSelected + + @State + private var isPressed = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .fontWeight(.semibold) + .foregroundStyle(isSelected ? .black : .white) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background { + ZStack { + EmptyHitTestView() + + if isSelected { + Rectangle() + .foregroundStyle(.white) + } + } + } + .overlay { + if !isSelected { + RoundedRectangle(cornerRadius: 7) + .stroke(Color.white, lineWidth: 4) + } + } + .mask { + RoundedRectangle(cornerRadius: 7) + } + .onTapGesture { + configuration.trigger() + + // TODO: disable if disabled + UIDevice.impact(.light) + } + .onLongPressGesture(minimumDuration: 0.01) {} onPressingChanged: { isPressing in + isPressed = isPressing + } + .scaleEffect( + x: isPressed ? 0.9 : 1, + y: isPressed ? 0.9 : 1, + anchor: .init(x: 0.5, y: 0.5) + ) + .animation(.bouncy(duration: 0.4), value: isPressed) + .opacity(isPressed ? 0.6 : 1) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift new file mode 100644 index 00000000..98a419cc --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift @@ -0,0 +1,152 @@ +// +// 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 IdentifiedCollections +import SwiftUI + +// TODO: possibly make custom tab view to have observe +// vertical scroll content and transfer to dismissal +// TODO: fix improper supplement selected +// - maybe a race issue + +extension VideoPlayer.UIVideoPlayerContainerViewController { + + struct SupplementContainerView: View { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + @EnvironmentObject + private var manager: MediaPlayerManager + + @State + private var currentSupplements: IdentifiedArrayOf = [] + + private var isPresentingOverlay: Bool { + containerState.isPresentingOverlay + } + + private var isScrubbing: Bool { + containerState.isScrubbing + } + + @ViewBuilder + private func supplementContainer(for supplement: some MediaPlayerSupplement) -> some View { + AlternateLayoutView(alignment: .topLeading) { + Color.clear + } content: { + supplement.videoPlayerBody + } + .background { + GestureView() + .environment(\.panGestureDirection, .vertical) + } + } + + var body: some View { + ZStack { + GestureView() + .environment(\.panGestureDirection, containerState.presentationControllerShouldDismiss ? .up : .vertical) + + VStack(spacing: EdgeInsets.edgePadding) { + + // TODO: scroll if larger than horizontal + HStack(spacing: 10) { + if containerState.isGuestSupplement, let supplement = containerState.selectedSupplement { + Button(supplement.displayTitle) { + containerState.select(supplement: nil) + } + .isSelected(true) + } else { + ForEach(currentSupplements) { supplement in + let isSelected = containerState.selectedSupplement?.id == supplement.id + + Button(supplement.displayTitle) { + containerState.select(supplement: supplement.supplement) + } + .isSelected(isSelected) + } + } + } + .buttonStyle(SupplementTitleButtonStyle()) + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + .edgePadding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack { + if containerState.isGuestSupplement, let supplement = containerState.selectedSupplement { + supplementContainer(for: supplement) + .eraseToAnyView() + } else { + TabView( + selection: $containerState.selectedSupplement.map( + getter: { $0?.id }, + setter: { id -> (any MediaPlayerSupplement)? in + id.map { currentSupplements[id: $0]?.supplement } ?? nil + } + ) + ) { + ForEach(currentSupplements) { supplement in + supplementContainer(for: supplement.supplement) + .eraseToAnyView() + .tag(supplement.id as String?) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + } + .isVisible(containerState.isPresentingSupplement) + .disabled(!containerState.isPresentingSupplement) + .animation(.linear(duration: 0.2), value: containerState.selectedSupplement?.id) + } + .edgePadding(.top) + .isVisible(isPresentingOverlay) + .isVisible(!isScrubbing) + } + .animation(.linear(duration: 0.2), value: isPresentingOverlay) + .animation(.linear(duration: 0.1), value: isScrubbing) + .animation(.bouncy(duration: 0.3, extraBounce: 0.1), value: currentSupplements) + .environment(\.isOverComplexContent, true) + .onReceive(manager.$supplements) { newValue in + let newSupplements = IdentifiedArray( + uniqueElements: newValue.map(AnyMediaPlayerSupplement.init) + ) + currentSupplements = newSupplements + } + .environment( + \.panAction, + .init( + action: { + containerState.containerView?.handlePanGesture( + translation: $0, + velocity: $1, + location: $2, + unitPoint: $3, + state: $4 + ) + } + ) + ) + .environment( + \.tapGestureAction, + .init( + action: { + containerState.containerView?.handleTapGesture( + location: $0, + unitPoint: $1, + count: $2 + ) + } + ) + ) + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/VideoPlayer+KeyCommands.swift b/Swiftfin/Views/VideoPlayerContainerView/VideoPlayer+KeyCommands.swift new file mode 100644 index 00000000..7c4c4676 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/VideoPlayer+KeyCommands.swift @@ -0,0 +1,185 @@ +// +// 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 PreferencesView +import SwiftUI +import VLCUI + +// TODO: protect against holding down + +extension VideoPlayer { + + struct KeyCommandsModifier: ViewModifier { + + @Default(.VideoPlayer.jumpBackwardInterval) + private var jumpBackwardInterval + @Default(.VideoPlayer.jumpForwardInterval) + private var jumpForwardInterval + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + @EnvironmentObject + private var manager: MediaPlayerManager + + @Toaster + private var toaster: ToastProxy + + func body(content: Content) -> some View { + content + .keyCommands { + + // MARK: Aspect Fill + + KeyCommandAction( + title: "Aspect Fill", + input: "f", + modifierFlags: .command + ) { @MainActor in + containerState.isAspectFilled.toggle() + } + + KeyCommandAction( + title: L10n.playAndPause, + input: " " + ) { + manager.togglePlayPause() + + if !containerState.isPresentingOverlay { + if manager.playbackRequestStatus == .paused { + toaster.present( + L10n.pause, + systemName: "pause.circle" + ) + } else if manager.playbackRequestStatus == .playing { + toaster.present( + L10n.play, + systemName: "play.circle" + ) + } + } + } + + // MARK: - Decrease Playback Speed + + KeyCommandAction( + title: "Decrease Playback Speed", + input: "[", + modifierFlags: .command + ) { + let newRate = clamp( + manager.rate - 0.25, + min: 0.25, + max: 4 + ) + + manager.setRate(rate: newRate) + + toaster.present( + Text(newRate, format: .playbackRate), + systemName: VideoPlayerActionButton.playbackSpeed.systemImage + ) + } + + // MARK: - Increase Playback Speed + + KeyCommandAction( + title: "Increase Playback Speed", + input: "]", + modifierFlags: .command + ) { + let newRate = clamp( + manager.rate + 0.25, + min: 0.25, + max: 4 + ) + + manager.setRate(rate: newRate) + + toaster.present( + Text(newRate, format: .playbackRate), + systemName: VideoPlayerActionButton.playbackSpeed.systemImage + ) + } + + // MARK: Reset Playback Speed + + KeyCommandAction( + title: "Reset Playback Speed", + input: "\\", + modifierFlags: .command + ) { + manager.setRate(rate: 1) + toaster.present( + Text(1, format: .playbackRate), + systemName: VideoPlayerActionButton.playbackSpeed.systemImage + ) + } + + // MARK: Play Next Item + + KeyCommandAction( + title: L10n.nextItem, + input: UIKeyCommand.inputRightArrow, + modifierFlags: .command + ) { + guard let nextItem = manager.queue?.nextItem else { return } + manager.playNewItem(provider: nextItem) + } + + // MARK: Play Previous Item + + KeyCommandAction( + title: L10n.previousItem, + input: UIKeyCommand.inputLeftArrow, + modifierFlags: .command + ) { + guard let previousItem = manager.queue?.previousItem else { return } + manager.playNewItem(provider: previousItem) + } + + // MARK: - Jump Backward + + KeyCommandAction( + title: L10n.jumpBackward, + input: UIKeyCommand.inputLeftArrow + ) { + containerState.jumpProgressObserver.jumpBackward() + manager.proxy?.jumpBackward(jumpBackwardInterval.rawValue) + + toaster.present( + Text( + jumpBackwardInterval.rawValue * containerState.jumpProgressObserver.jumps, + format: .minuteSecondsAbbreviated + ), + systemName: "gobackward" + ) + } + + // MARK: - Jump Forward + + KeyCommandAction( + title: L10n.jumpForward, + input: UIKeyCommand.inputRightArrow + ) { + containerState.jumpProgressObserver.jumpForward() + manager.proxy?.jumpForward(jumpForwardInterval.rawValue) + + toaster.present( + Text( + jumpForwardInterval.rawValue * containerState.jumpProgressObserver.jumps, + format: .minuteSecondsAbbreviated + ), + systemName: "goforward" + ) + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayerContainerView/VideoPlayerContainerView.swift b/Swiftfin/Views/VideoPlayerContainerView/VideoPlayerContainerView.swift new file mode 100644 index 00000000..dbdc1272 --- /dev/null +++ b/Swiftfin/Views/VideoPlayerContainerView/VideoPlayerContainerView.swift @@ -0,0 +1,624 @@ +// +// 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 Combine +import Defaults +import Engine +import Logging +import SwiftUI + +// TODO: don't dismiss overlay while panning and supplement not presented +// TODO: use video size from proxies to control aspect fill +// - stay within safe areas, aspect fill to screen +// TODO: instead of static sizes for supplement view, take into account available space +// - necessary for full-screen supplements and/or small screens +// TODO: custom buttons on playback controls +// - skip intro, next episode, etc. +// - can just do on playback controls itself +// TODO: pass in safe area insets explicitly? +// TODO: pause when center tapped when overlay dismissed +// - can be done entirely on playback controls layer +// TODO: no supplements state +// - don't pan +// TODO: account for gesture state active when item changes +// TODO: only show player view if not error/other bad states +// - only show when have item? +// - helps with not rendering before ready +// - would require refactor so that video players take media player items + +// MARK: - VideoPlayerContainerView + +extension VideoPlayer { + struct VideoPlayerContainerView: UIViewControllerRepresentable { + + private let containerState: VideoPlayerContainerState + private let manager: MediaPlayerManager + private let player: () -> Player + private let playbackControls: () -> PlaybackControls + + init( + containerState: VideoPlayerContainerState, + manager: MediaPlayerManager, + @ViewBuilder player: @escaping () -> Player, + @ViewBuilder playbackControls: @escaping () -> PlaybackControls + ) { + self.containerState = containerState + self.manager = manager + self.player = player + self.playbackControls = playbackControls + } + + func makeUIViewController(context: Context) -> UIVideoPlayerContainerViewController { + let playerView = player() + .environment(\.audioOffset, context.environment.audioOffset) + .eraseToAnyView() + let playbackControlsView = playbackControls() + .environment(\.audioOffset, context.environment.audioOffset) + .eraseToAnyView() + + return UIVideoPlayerContainerViewController( + containerState: containerState, + manager: manager, + player: playerView, + playbackControls: playbackControlsView + ) + } + + func updateUIViewController( + _ uiViewController: UIVideoPlayerContainerViewController, + context: Context + ) {} + } + + // MARK: - UIVideoPlayerContainerViewController + + class UIVideoPlayerContainerViewController: UIViewController { + + // MARK: - Views + + // TODO: preview image while scrubbing option + private struct PlayerContainerView: View { + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + let player: AnyView + + private var shouldPresentDimOverlay: Bool { + if containerState.isScrubbing { + return false + } + + if containerState.isCompact { + return containerState.isPresentingPlaybackControls + } else { + return containerState.isPresentingOverlay + } + } + + var body: some View { + player + .overlay(Color.black.opacity(shouldPresentDimOverlay ? 0.5 : 0.0)) + .animation(.linear(duration: 0.2), value: containerState.isPresentingPlaybackControls) + } + } + + private struct PlaybackControlsContainerView: View { + + @Default(.VideoPlayer.Gesture.horizontalSwipeAction) + private var horizontalSwipeAction + + @EnvironmentObject + private var containerState: VideoPlayerContainerState + + let playbackControls: AnyView + + var body: some View { + OverlayToastView(proxy: containerState.toastProxy) { + ZStack { + GestureView() + .environment( + \.panGestureDirection, + containerState.presentationControllerShouldDismiss ? .allButDown : .vertical + ) + + playbackControls + } + // inject box explicitly + .environmentObject(containerState.scrubbedSeconds) + } + .environment( + \.longPressAction, + .init( + action: { + containerState.containerView?.handleLongPressGesture( + location: $0, + unitPoint: $1, + state: $2 + ) + } + ) + ) + .environment( + \.panAction, + .init( + action: { + containerState.containerView?.handlePanGesture( + translation: $0, + velocity: $1, + location: $2, + unitPoint: $3, + state: $4 + ) + } + ) + ) + .environment( + \.pinchAction, + .init( + action: { + containerState.containerView?.handlePinchGesture(scale: $0, velocity: $1, state: $2) + } + ) + ) + .environment( + \.tapGestureAction, + .init( + action: { + containerState.containerView?.handleTapGesture( + location: $0, + unitPoint: $1, + count: $2 + ) + } + ) + ) + } + } + + private lazy var initialHitBlockView: UIView = { + let view = UIView(frame: .zero) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var playerViewController: HostingController = { + let controller = HostingController( + content: PlayerContainerView(player: player) + .environmentObject(containerState) + .environmentObject(manager) + .eraseToAnyView() + ) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private lazy var playbackControlsViewController: HostingController = { + let controller = HostingController( + content: PlaybackControlsContainerView(playbackControls: playbackControls) + .environmentObject(containerState) + .environmentObject(manager) + .eraseToAnyView() + ) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private lazy var supplementContainerViewController: HostingController = { + let content = SupplementContainerView() + .environmentObject(containerState) + .environmentObject(manager) + .eraseToAnyView() + let controller = HostingController(content: content) + controller.disablesSafeArea = true + controller.automaticallyAllowUIKitAnimationsForNextUpdate = true + controller.view.translatesAutoresizingMaskIntoConstraints = false + return controller + }() + + private var playerView: UIView { playerViewController.view } + private var playbackControlsView: UIView { playbackControlsViewController.view } + private var supplementContainerView: UIView { supplementContainerViewController.view } + + // MARK: - Constants + + private let compactSupplementContainerOffset: (CGFloat) -> CGFloat = { totalHeight in + max(totalHeight * 0.6, 300) + EdgeInsets.edgePadding * 2 + } + + private let regularSupplementContainerOffset: CGFloat = 200.0 + EdgeInsets.edgePadding * 2 + private let dismissedSupplementContainerOffset: CGFloat = 50.0 + EdgeInsets.edgePadding * 2 + + private let compactMinimumTranslation: CGFloat = 100.0 + private let regularMinimumTranslation: CGFloat = 50.0 + + // MARK: - Constraints + + private var playbackControlsConstraints: [NSLayoutConstraint] = [] + private var playerCompactConstraints: [NSLayoutConstraint] = [] + private var playerRegularConstraints: [NSLayoutConstraint] = [] + private var supplementContainerConstraints: [NSLayoutConstraint] = [] + + private var playerCompactBottomAnchor: NSLayoutConstraint! + private var supplementHeightAnchor: NSLayoutConstraint! + private var supplementBottomAnchor: NSLayoutConstraint! + + private var centerOffset: CGFloat { + guard containerState.isCompact else { + return dismissedSupplementContainerOffset + } + + let supplementContainerHeight = compactSupplementContainerOffset(view.bounds.height) + let offsetPercentage = 1 - clamp(supplementBottomAnchor.constant.magnitude / supplementContainerHeight, min: 0, max: 1) + let offset = (dismissedSupplementContainerOffset + EdgeInsets.edgePadding) * offsetPercentage + + return max(50, offset) + } + + private var compactPlayerBottomOffset: CGFloat { + guard containerState.isCompact else { + return dismissedSupplementContainerOffset + } + let supplementContainerHeight = compactSupplementContainerOffset(view.bounds.height) + let offsetPercentage = 1 - clamp(supplementBottomAnchor.constant.magnitude / supplementContainerHeight, min: 0, max: 1) + let offset = (dismissedSupplementContainerOffset + EdgeInsets.edgePadding) * offsetPercentage + + return offset + } + + private let logger = Logger.swiftfin() + private let manager: MediaPlayerManager + private let player: AnyView + private let playbackControls: AnyView + let containerState: VideoPlayerContainerState + + private var cancellables: Set = [] + private var didInitiallyAppear: Bool = false + + init( + containerState: VideoPlayerContainerState, + manager: MediaPlayerManager, + player: AnyView, + playbackControls: AnyView + ) { + self.containerState = containerState + self.manager = manager + self.player = player + self.playbackControls = playbackControls + + super.init(nibName: nil, bundle: nil) + + containerState.containerView = self + containerState.manager = manager + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // TODO: don't force unwrap optional, sometimes gets into weird state + private var lastVerticalPanLocation: CGPoint? + private var verticalPanGestureStartConstant: CGFloat? + private var isPanning: Bool = false + private var didStartPanningWithSupplement: Bool = false + private var didStartPanningUpWithoutOverlay: Bool = false + + // MARK: - Supplement Pan Action + + func handleSupplementPanAction( + translation: CGPoint, + velocity: CGFloat, + location: CGPoint, + state: UIGestureRecognizer.State + ) { + let yDirection: CGFloat = translation.y > 0 ? -1 : 1 + let newOffset: CGFloat + let clampedOffset: CGFloat + + if state == .began { + self.view.layer.removeAllAnimations() + didStartPanningWithSupplement = containerState.selectedSupplement != nil + verticalPanGestureStartConstant = supplementBottomAnchor.constant + didStartPanningUpWithoutOverlay = !containerState.isPresentingOverlay + if didStartPanningUpWithoutOverlay { + containerState.isPresentingOverlay = true + } + } + + if state == .began || state == .changed { + lastVerticalPanLocation = location + isPanning = true + + let minimumTranslation = + -((containerState.isCompact ? compactMinimumTranslation : regularMinimumTranslation) + + dismissedSupplementContainerOffset + ) + let shouldHaveSupplementPresented = self.supplementBottomAnchor.constant < minimumTranslation + + if shouldHaveSupplementPresented, !containerState.isPresentingSupplement { + containerState.selectedSupplement = manager.supplements.first + } else if !shouldHaveSupplementPresented, containerState.selectedSupplement != nil { + containerState.selectedSupplement = nil + } + } else { + lastVerticalPanLocation = nil + verticalPanGestureStartConstant = nil + isPanning = false + + let translationMin: CGFloat = containerState.isCompact ? compactMinimumTranslation : regularMinimumTranslation + let shouldActuallyDismissSupplement = didStartPanningWithSupplement && (translation.y > translationMin || velocity > 1000) + if shouldActuallyDismissSupplement { + // If we started with a supplement and panned down more than 100 points, dismiss it + containerState.selectedSupplement = nil + } + + let shouldActuallyPresentSupplement = !didStartPanningWithSupplement && + (translation.y < -translationMin || velocity < -1000) + if shouldActuallyPresentSupplement { + // If we didn't start with a supplement and panned up more than 100 points, present it + containerState.selectedSupplement = manager.supplements.first + } + + let stateToPass: (translation: CGFloat, velocity: CGFloat)? = lastVerticalPanLocation != nil && + verticalPanGestureStartConstant != + nil ? + (translation: translation.y, velocity: velocity) : nil + presentSupplementContainer(containerState.selectedSupplement != nil, with: stateToPass) + + let shouldActuallyDismissOverlay = didStartPanningUpWithoutOverlay && !containerState.isPresentingSupplement + + if shouldActuallyDismissOverlay { + containerState.isPresentingOverlay = false + } + return + } + + guard let verticalPanGestureStartConstant else { + logger.error("Vertical pan gesture invalid state: verticalPanGestureStartConstant is nil") + return + } + + if (!didStartPanningWithSupplement && yDirection > 0) || (didStartPanningWithSupplement && yDirection < 0) { + // If we started with a supplement and are panning down, or if we didn't start with a supplement and are panning up + newOffset = verticalPanGestureStartConstant + (translation.y.magnitude * -yDirection) + } else { + newOffset = verticalPanGestureStartConstant - (translation.y.magnitude * yDirection) + } + + if containerState.isCompact { + clampedOffset = clamp( + newOffset, + min: -compactSupplementContainerOffset(view.bounds.height), + max: -dismissedSupplementContainerOffset + ) + } else { + clampedOffset = clamp( + newOffset, + min: -regularSupplementContainerOffset, + max: -dismissedSupplementContainerOffset + ) + } + + if newOffset < clampedOffset { + let excess = clampedOffset - newOffset + let resistance = pow(excess, 0.7) + supplementBottomAnchor.constant = clampedOffset - resistance + } else if newOffset > -dismissedSupplementContainerOffset { + let excess = newOffset - clampedOffset + let resistance = pow(excess, 0.5) + supplementBottomAnchor.constant = clamp(clampedOffset + resistance, min: -dismissedSupplementContainerOffset, max: -50) + } else { + supplementBottomAnchor.constant = clampedOffset + } + + playerCompactBottomAnchor.constant = compactPlayerBottomOffset + containerState.supplementOffset = supplementBottomAnchor.constant + containerState.centerOffset = centerOffset + } + + // MARK: - present + + func presentSupplementContainer( + _ didPresent: Bool, + with panningState: (translation: CGFloat, velocity: CGFloat)? = nil + ) { + guard !isPanning else { return } + + if didPresent { + if containerState.isCompact { + self.supplementBottomAnchor.constant = -compactSupplementContainerOffset(view.bounds.size.height) + } else { + self.supplementBottomAnchor.constant = -regularSupplementContainerOffset + } + } else { + self.supplementBottomAnchor.constant = -dismissedSupplementContainerOffset + } + + playerCompactBottomAnchor.constant = compactPlayerBottomOffset + containerState.supplementOffset = supplementBottomAnchor.constant + containerState.centerOffset = centerOffset + + if let panningState { + let velocity = panningState.velocity.magnitude / 1000 + let distance = panningState.translation.magnitude + let duration = min(max(Double(distance) / Double(velocity * 1000), 0.2), 0.75) + + UIView.animate( + withDuration: duration, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: velocity, + options: .allowUserInteraction + ) { [weak self] in + self?.view.layoutIfNeeded() + } + } else { + UIView.animate( + withDuration: containerState.isCompact ? 0.75 : 0.6, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.4, + options: .allowUserInteraction + ) { [weak self] in + self?.view.layoutIfNeeded() + } + } + } + + // MARK: - viewDidAppear + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !didInitiallyAppear { + containerState.isPresentingOverlay = true + setupPlayerView() + initialHitBlockView.removeFromSuperview() + didInitiallyAppear = true + } + } + + // MARK: - viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + containerState.isCompact = UIDevice.isPhone && view.bounds.size.isPortrait + + setupOnLoadViews() + setupOnLoadConstraints() + } + + // Setup player view separately after view appears to hopefully + // prevent player playing before the view is done presenting + private func setupPlayerView() { + addChild(playerViewController) + view.addSubview(playerView) + view.sendSubviewToBack(playerView) + playerViewController.didMove(toParent: self) + playerView.backgroundColor = .black + + playerCompactBottomAnchor = playerView.bottomAnchor.constraint( + equalTo: supplementContainerView.topAnchor, + constant: compactPlayerBottomOffset + ) + + playerCompactConstraints = [ + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerCompactBottomAnchor, + ] + playerRegularConstraints = [ + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + if containerState.isCompact { + NSLayoutConstraint.activate(playerCompactConstraints) + } else { + NSLayoutConstraint.activate(playerRegularConstraints) + } + } + + private func setupOnLoadViews() { + addChild(playbackControlsViewController) + view.addSubview(playbackControlsView) + playbackControlsViewController.didMove(toParent: self) + playbackControlsView.backgroundColor = .clear + + addChild(supplementContainerViewController) + view.addSubview(supplementContainerView) + supplementContainerViewController.didMove(toParent: self) + supplementContainerView.backgroundColor = .clear + + view.addSubview(initialHitBlockView) + view.bringSubviewToFront(initialHitBlockView) + } + + private func setupOnLoadConstraints() { + + let isCompact = UIDevice.isPhone && view.bounds.size.isPortrait + + supplementBottomAnchor = supplementContainerView.topAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -dismissedSupplementContainerOffset + ) + containerState.supplementOffset = supplementBottomAnchor.constant + containerState.centerOffset = centerOffset + + let constant = isCompact ? + compactSupplementContainerOffset(view.bounds.height) : + regularSupplementContainerOffset + supplementHeightAnchor = supplementContainerView.heightAnchor.constraint(equalToConstant: constant) + + supplementContainerConstraints = [ + supplementContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + supplementContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + supplementBottomAnchor, + supplementHeightAnchor, + ] + + NSLayoutConstraint.activate(supplementContainerConstraints) + + playbackControlsConstraints = [ + playbackControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playbackControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playbackControlsView.topAnchor.constraint(equalTo: view.topAnchor), + playbackControlsView.bottomAnchor.constraint(equalTo: supplementContainerView.topAnchor), + ] + + NSLayoutConstraint.activate(playbackControlsConstraints) + + NSLayoutConstraint.activate([ + initialHitBlockView.topAnchor.constraint(equalTo: view.topAnchor), + initialHitBlockView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + initialHitBlockView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + initialHitBlockView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + adjustContraints(isCompact: UIDevice.isPhone && size.isPortrait, in: size) + } + + private func adjustContraints(isCompact: Bool, in newSize: CGSize) { + containerState.isCompact = isCompact + + if isCompact { + NSLayoutConstraint.deactivate(playerRegularConstraints) + NSLayoutConstraint.activate(playerCompactConstraints) + + supplementBottomAnchor.constant = containerState + .isPresentingSupplement ? -compactSupplementContainerOffset(newSize.height) : -dismissedSupplementContainerOffset + supplementHeightAnchor.constant = compactSupplementContainerOffset(newSize.height) + } else { + NSLayoutConstraint.deactivate(playerCompactConstraints) + NSLayoutConstraint.activate(playerRegularConstraints) + + supplementBottomAnchor.constant = containerState + .isPresentingSupplement ? -regularSupplementContainerOffset : -dismissedSupplementContainerOffset + supplementHeightAnchor.constant = regularSupplementContainerOffset + } + + playerCompactBottomAnchor.constant = compactPlayerBottomOffset + containerState.supplementOffset = supplementHeightAnchor.constant + containerState.centerOffset = centerOffset + } + } +} diff --git a/Translations/km.lproj/Localizable.strings b/Translations/km.lproj/Localizable.strings new file mode 100644 index 00000000..dcab52cb Binary files /dev/null and b/Translations/km.lproj/Localizable.strings differ diff --git a/Translations/mn.lproj/Localizable.strings b/Translations/mn.lproj/Localizable.strings new file mode 100644 index 00000000..ed60b89c Binary files /dev/null and b/Translations/mn.lproj/Localizable.strings differ diff --git a/XcodeConfig/Shared.xcconfig b/XcodeConfig/Shared.xcconfig new file mode 100644 index 00000000..4d1ecaa7 --- /dev/null +++ b/XcodeConfig/Shared.xcconfig @@ -0,0 +1,12 @@ +// +// 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 +// + +DEVELOPMENT_TEAM = +PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin + +#include? "DevelopmentTeam.xcconfig" diff --git a/add_epg_files.rb b/add_epg_files.rb new file mode 100644 index 00000000..d1139d55 --- /dev/null +++ b/add_epg_files.rb @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +require 'xcodeproj' + +project_path = 'jellypig.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Find the main target +target = project.targets.find { |t| t.name == 'jellypig tvOS' } + +# Files to add with their paths and group locations +files_to_add = [ + { + path: 'Shared/ViewModels/EPGViewModel.swift', + group_path: ['jellypig', 'Shared', 'ViewModels'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + } +] + +files_to_add.each do |file_info| + file_path = file_info[:path] + absolute_path = File.join(Dir.pwd, file_path) + + # Navigate to the correct group + group = project.main_group + file_info[:group_path].each do |group_name| + group = group.groups.find { |g| g.name == group_name || g.path == group_name } || group.new_group(group_name) + end + + # Check if file already exists + existing_file = group.files.find { |f| f.path == File.basename(file_path) } + + if existing_file + puts "File already exists: #{file_path}" + else + # Add file reference with absolute path, Xcode will convert to relative + file_ref = group.new_reference(absolute_path) + + # Add to target's source build phase + target.source_build_phase.add_file_reference(file_ref) + + puts "Added: #{file_path}" + end +end + +# Save the project +project.save + +puts "\nProject updated successfully!" diff --git a/add_programguideview.rb b/add_programguideview.rb new file mode 100644 index 00000000..fdc965a4 --- /dev/null +++ b/add_programguideview.rb @@ -0,0 +1,43 @@ +#!/usr/bin/env ruby +require 'xcodeproj' + +project_path = 'jellypig.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Find the main target +target = project.targets.find { |t| t.name == 'jellypig tvOS' } + +file_path = 'jellypig tvOS/Views/ProgramGuideView.swift' +absolute_path = File.join(Dir.pwd, file_path) + +unless File.exist?(absolute_path) + puts "ERROR: File does not exist: #{absolute_path}" + exit 1 +end + +# Navigate to correct group +group = project.main_group +['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView'].each do |group_name| + found_group = group.groups.find { |g| g.name == group_name || g.path == group_name } + group = found_group || group.new_group(group_name) +end + +# Check if already exists +existing_file = group.files.find { |f| f.path == File.basename(file_path) } + +if existing_file + puts "File already exists in project" +else + # Add file reference + file_ref = group.new_reference(absolute_path) + + # Add to target's source build phase + target.source_build_phase.add_file_reference(file_ref) + + puts "Added: #{file_path}" +end + +# Save the project +project.save + +puts "Done!" diff --git a/fix_epg_files.rb b/fix_epg_files.rb new file mode 100644 index 00000000..8ebcbc6e --- /dev/null +++ b/fix_epg_files.rb @@ -0,0 +1,91 @@ +#!/usr/bin/env ruby +require 'xcodeproj' + +project_path = 'jellypig.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Find the main target +target = project.targets.find { |t| t.name == 'jellypig tvOS' } + +#Step 1: Remove ALL instances of EPG files +file_names = ['EPGViewModel.swift', 'EPGProgramCell.swift', 'EPGChannelRow.swift', + 'EPGTimelineHeader.swift', 'EPGCurrentTimeIndicator.swift'] + +puts "=== Step 1: Removing all EPG file references ===" + +file_names.each do |file_name| + # Remove from build phase (all instances) + target.source_build_phase.files.delete_if do |build_file| + if build_file.file_ref && (build_file.file_ref.path == file_name || build_file.file_ref.path&.end_with?(file_name)) + puts "Removed from build phase: #{file_name}" + true + else + false + end + end + + # Remove file references (all instances) + project.main_group.recursive_children.each do |child| + if child.is_a?(Xcodeproj::Project::Object::PBXFileReference) && + (child.path == file_name || child.path&.end_with?(file_name)) + child.remove_from_project + puts "Removed reference: #{file_name}" + end + end +end + +puts "\n=== Step 2: Adding EPG files with correct paths ===" + +# Files to add with their paths and group locations +files_to_add = [ + { + path: 'Shared/ViewModels/EPGViewModel.swift', + group_path: ['jellypig', 'Shared', 'ViewModels'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + }, + { + path: 'jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift', + group_path: ['jellypig', 'jellypig tvOS', 'Views', 'ProgramGuideView', 'Components'] + } +] + +files_to_add.each do |file_info| + file_path = file_info[:path] + absolute_path = File.join(Dir.pwd, file_path) + + unless File.exist?(absolute_path) + puts "ERROR: File does not exist: #{absolute_path}" + next + end + + # Navigate to the correct group + group = project.main_group + file_info[:group_path].each do |group_name| + found_group = group.groups.find { |g| g.name == group_name || g.path == group_name } + group = found_group || group.new_group(group_name) + end + + # Add file reference + file_ref = group.new_reference(absolute_path) + + # Add to target's source build phase (only once!) + target.source_build_phase.add_file_reference(file_ref) + + puts "Added: #{file_path}" +end + +# Save the project +project.save + +puts "\n=== Done! Project updated successfully! ===" diff --git a/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift new file mode 100644 index 00000000..00c64838 --- /dev/null +++ b/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -0,0 +1,135 @@ +// +// 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 +// + +// TODO: IMPLEMENT BUTTON OVERRIDES IN `PreferencesView` PACKAGE + +// import SwiftUI +// import UIKit +// +//// MARK: PreferenceUIHostingController +// +// class PreferenceUIHostingController: UIHostingController { +// +// init(@ViewBuilder wrappedView: @escaping () -> V) { +// let box = Box() +// super.init(rootView: AnyView( +// wrappedView() +// .onPreferenceChange(ViewPreferenceKey.self) { +// box.value?._viewPreference = $0 +// } +// .onPreferenceChange(DidPressMenuPreferenceKey.self) { +// box.value?.didPressMenuAction = $0 +// } +// .onPreferenceChange(DidPressSelectPreferenceKey.self) { +// box.value?.didPressSelectAction = $0 +// } +// )) +// box.value = self +// +// addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenuSelector)) +// addButtonPressRecognizer(pressType: .select, action: #selector(didPressSelectSelector)) +// } +// +// @objc +// dynamic required init?(coder aDecoder: NSCoder) { +// super.init(coder: aDecoder) +// super.modalPresentationStyle = .fullScreen +// } +// +// private class Box { +// weak var value: PreferenceUIHostingController? +// init() {} +// } +// +// public var _viewPreference: UIUserInterfaceStyle = .unspecified { +// didSet { +// overrideUserInterfaceStyle = _viewPreference +// } +// } +// +// var didPressMenuAction: ActionHolder = .init(action: {}) +// var didPressSelectAction: ActionHolder = .init(action: {}) +// +// private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { +// let pressRecognizer = UITapGestureRecognizer() +// pressRecognizer.addTarget(self, action: action) +// pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] +// view.addGestureRecognizer(pressRecognizer) +// } +// +// @objc +// private func didPressMenuSelector() { +// DispatchQueue.main.async { +// self.didPressMenuAction.action() +// } +// } +// +// @objc +// private func didPressSelectSelector() { +// DispatchQueue.main.async { +// self.didPressSelectAction.action() +// } +// } +// } +// +// struct ActionHolder: Equatable { +// +// static func == (lhs: ActionHolder, rhs: ActionHolder) -> Bool { +// lhs.uuid == rhs.uuid +// } +// +// var action: () -> Void +// let uuid = UUID().uuidString +// } +// +//// MARK: Preference Keys +// +// struct ViewPreferenceKey: PreferenceKey { +// typealias Value = UIUserInterfaceStyle +// +// static var defaultValue: UIUserInterfaceStyle = .unspecified +// +// static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { +// value = nextValue() +// } +// } +// +// struct DidPressMenuPreferenceKey: PreferenceKey { +// +// static var defaultValue: ActionHolder = .init(action: {}) +// +// static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { +// value = nextValue() +// } +// } +// +// struct DidPressSelectPreferenceKey: PreferenceKey { +// +// static var defaultValue: ActionHolder = .init(action: {}) +// +// static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { +// value = nextValue() +// } +// } +// +//// MARK: Preference Key View Extension +// +// extension View { +// +// func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { +// preference(key: ViewPreferenceKey.self, value: viewPreference) +// } +// +// func onMenuPressed(_ action: @escaping () -> Void) -> some View { +// preference(key: DidPressMenuPreferenceKey.self, value: ActionHolder(action: action)) +// } +// +// func onSelectPressed(_ action: @escaping () -> Void) -> some View { +// preference(key: DidPressSelectPreferenceKey.self, value: ActionHolder(action: action)) +// } +// } diff --git a/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift new file mode 100644 index 00000000..feafce53 --- /dev/null +++ b/jellyflood tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -0,0 +1,82 @@ +// +// 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 +// + +// TODO: IMPLEMENT BUTTON OVERRIDES IN `PreferencesView` PACKAGE + +// import SwiftUI +// import SwizzleSwift +// import UIKit +// +//// MARK: - wrapper view +// +///// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. +///// Used in combination with PreferenceUIHostingController. +///// +///// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d +// struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { +// init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { +// _ = UIViewController.preferenceSwizzling +// self.wrappedView = wrappedView +// } +// +// var wrappedView: () -> Wrapped +// +// func makeUIViewController(context: Context) -> PreferenceUIHostingController { +// PreferenceUIHostingController { wrappedView() } +// } +// +// func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} +// } +// +//// MARK: - swizzling uiviewcontroller extensions +// +// extension UIViewController { +// static var preferenceSwizzling: Void = { +// Swizzle(UIViewController.self) { +//// #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> +/// #selector(swizzled_childForScreenEdgesDeferringSystemGestures) +//// #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) +// } +// }() +// } +// +// extension UIViewController { +// @objc +// func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { +// if self is PreferenceUIHostingController { +// // dont continue searching +// return nil +// } else { +// return search() +// } +// } +// +// @objc +// func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { +// if self is PreferenceUIHostingController { +// // dont continue searching +// return nil +// } else { +// return search() +// } +// } +// +// private func search() -> PreferenceUIHostingController? { +// if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { +// return result +// } +// +// for child in children { +// if let result = child.search() { +// return result +// } +// } +// +// return nil +// } +// } diff --git a/jellypig tvOS/App/jellypigapp.swift b/jellyflood tvOS/App/jellyfloodapp.swift similarity index 100% rename from jellypig tvOS/App/jellypigapp.swift rename to jellyflood tvOS/App/jellyfloodapp.swift diff --git a/jellyflood tvOS/App/jellypigapp 2.swift b/jellyflood tvOS/App/jellypigapp 2.swift new file mode 100644 index 00000000..b55f27d7 --- /dev/null +++ b/jellyflood tvOS/App/jellypigapp 2.swift @@ -0,0 +1,81 @@ +// +// 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 CoreStore +import Defaults +import Factory +import Logging +import Nuke +import Pulse +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + init() { + + // CoreStore + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + + // Logging + LoggingSystem.bootstrap { label in + + var loggers: [LogHandler] = [PersistentLogHandler(label: label).withLogLevel(.trace)] + + #if DEBUG + loggers.append(SwiftfinConsoleLogger()) + #endif + + return MultiplexLogHandler(loggers) + } + + // Nuke + + ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB + ImageCache.shared.ttl = 300 // 5 min + + ImageDecoderRegistry.shared.register { context in + guard let mimeType = context.urlResponse?.mimeType else { return nil } + return mimeType.contains("svg") ? ImageDecoders.Empty() : nil + } + + ImagePipeline.shared = .Swiftfin.posters + + // UIKit + + UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] + + // don't keep last user id + if Defaults[.signOutOnClose] { + Defaults[.lastSignedInUserID] = .signedOut + } + } + + var body: some Scene { + WindowGroup { + MainCoordinator() + .view() + .onNotification(.applicationDidEnterBackground) { + Defaults[.backgroundTimeStamp] = Date.now + } + .onNotification(.applicationWillEnterForeground) { + // TODO: needs to check if any background playback is happening + let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) + + if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { + Defaults[.lastSignedInUserID] = .signedOut + Container.shared.currentUserSession.reset() + Notifications[.didSignOut].post() + } + } + } + } +} diff --git a/jellyflood tvOS/Components 2/CapsuleSlider.swift b/jellyflood tvOS/Components 2/CapsuleSlider.swift new file mode 100644 index 00000000..3d811cd7 --- /dev/null +++ b/jellyflood tvOS/Components 2/CapsuleSlider.swift @@ -0,0 +1,56 @@ +// +// 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 CapsuleSlider: View { + + @Binding + private var value: Value + + @FocusState + private var isFocused: Bool + + private let total: Value + private var onEditingChanged: (Bool) -> Void + + init(value: Binding, total: Value) { + self._value = value + self.total = total + self.onEditingChanged = { _ in } + } + + var body: some View { + SliderContainer( + value: $value, + total: total, + onEditingChanged: onEditingChanged + ) { + CapsuleSliderContent() + } + } +} + +extension CapsuleSlider { + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } +} + +private struct CapsuleSliderContent: SliderContentView { + + @EnvironmentObject + var sliderState: SliderContainerState + + var body: some View { + ProgressView(value: sliderState.value, total: sliderState.total) + .progressViewStyle(PlaybackProgressViewStyle(cornerStyle: .round)) + .frame(height: 30) + } +} diff --git a/jellyflood tvOS/Components 2/CinematicBackgroundView.swift b/jellyflood tvOS/Components 2/CinematicBackgroundView.swift new file mode 100644 index 00000000..83cde560 --- /dev/null +++ b/jellyflood tvOS/Components 2/CinematicBackgroundView.swift @@ -0,0 +1,61 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +struct CinematicBackgroundView: View { + + @ObservedObject + var viewModel: Proxy + + @StateObject + private var proxy: RotateContentView.Proxy = .init() + + var initialItem: (any Poster)? + + var body: some View { + RotateContentView(proxy: proxy) + .onChange(of: viewModel.currentItem) { _, newItem in + proxy.update { + ImageView(newItem?.cinematicImageSources(maxWidth: nil) ?? []) + .placeholder { _ in + Color.clear + } + .failure { + Color.clear + } + .aspectRatio(contentMode: .fill) + } + } + } + + class Proxy: ObservableObject { + + @Published + var currentItem: AnyPoster? + + private var cancellables = Set() + private var currentItemSubject = CurrentValueSubject(nil) + + init() { + currentItemSubject + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { newItem in + self.currentItem = newItem + } + .store(in: &cancellables) + } + + func select(item: some Poster) { + currentItemSubject.send(AnyPoster(item)) + } + } +} diff --git a/jellyflood tvOS/Components 2/CinematicItemSelector.swift b/jellyflood tvOS/Components 2/CinematicItemSelector.swift new file mode 100644 index 00000000..742fe433 --- /dev/null +++ b/jellyflood tvOS/Components 2/CinematicItemSelector.swift @@ -0,0 +1,113 @@ +// +// 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 Combine +import JellyfinAPI +import SwiftUI + +// TODO: make new protocol for cinematic view image provider +// TODO: better name + +struct CinematicItemSelector: View { + + @FocusState + private var isSectionFocused + + @FocusedValue(\.focusedPoster) + private var focusedPoster + + @StateObject + private var viewModel: CinematicBackgroundView.Proxy = .init() + + private var topContent: (Item) -> any View + private var itemContent: (Item) -> any View + private var trailingContent: () -> any View + private var onSelect: (Item) -> Void + + let items: [Item] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let focusedPoster, let focusedItem = focusedPoster._poster as? Item { + topContent(focusedItem) + .eraseToAnyView() + .id(focusedItem.hashValue) + .transition(.opacity) + } + + // TODO: fix intrinsic content sizing without frame + PosterHStack( + type: .landscape, + items: items, + action: onSelect, + label: itemContent + ) + .frame(height: 400) + } + .frame(height: UIScreen.main.bounds.height - 75, alignment: .bottomLeading) + .frame(maxWidth: .infinity) + .background(alignment: .top) { + CinematicBackgroundView( + viewModel: viewModel, + initialItem: items.first + ) + .overlay { + Color.black + .maskLinearGradient { + (location: 0.5, opacity: 0) + (location: 0.6, opacity: 0.4) + (location: 1, opacity: 1) + } + } + .frame(height: UIScreen.main.bounds.height) + .maskLinearGradient { + (location: 0.9, opacity: 1) + (location: 1, opacity: 0) + } + } + .onChange(of: focusedPoster) { + guard let focusedPoster, isSectionFocused else { return } + viewModel.select(item: focusedPoster) + } + .focusSection() + .focused($isSectionFocused) + } +} + +extension CinematicItemSelector { + + init(items: [Item]) { + self.init( + topContent: { _ in EmptyView() }, + itemContent: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in }, + items: items + ) + } +} + +extension CinematicItemSelector { + + func topContent(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.itemContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> T) -> Self { + copy(modifying: \.trailingContent, with: content) + } + + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components 2/DotHStack.swift b/jellyflood tvOS/Components 2/DotHStack.swift new file mode 100644 index 00000000..3954cd35 --- /dev/null +++ b/jellyflood tvOS/Components 2/DotHStack.swift @@ -0,0 +1,22 @@ +// +// 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 + +func DotHStack( + padding: CGFloat = 10, + @ViewBuilder content: @escaping () -> Content +) -> some View { + SeparatorHStack { + Circle() + .frame(width: 5, height: 5) + .padding(.horizontal, 10) + } content: { + content() + } +} diff --git a/jellyflood tvOS/Components 2/EnumPickerView.swift b/jellyflood tvOS/Components 2/EnumPickerView.swift new file mode 100644 index 00000000..87640f8a --- /dev/null +++ b/jellyflood tvOS/Components 2/EnumPickerView.swift @@ -0,0 +1,60 @@ +// +// 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 EnumPickerView: View { + + @Binding + private var selection: EnumType + + private var descriptionView: () -> any View + private var title: String? + + var body: some View { + SplitFormWindowView() + .descriptionView(descriptionView) + .contentView { + Section { + ForEach(EnumType.allCases.asArray, id: \.hashValue) { item in + Button { + selection = item + } label: { + HStack { + Text(item.displayTitle) + + Spacer() + + if selection == item { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } + } +} + +extension EnumPickerView { + + init( + title: String? = nil, + selection: Binding + ) { + self.init( + selection: selection, + descriptionView: { EmptyView() }, + title: title + ) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/jellyflood tvOS/Components 2/ErrorView.swift b/jellyflood tvOS/Components 2/ErrorView.swift new file mode 100644 index 00000000..d3bceda8 --- /dev/null +++ b/jellyflood tvOS/Components 2/ErrorView.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView: View { + + @Default(.accentColor) + private var accentColor + + private let error: ErrorType + private var onRetry: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 150)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(minWidth: 250, maxWidth: 750) + .multilineTextAlignment(.center) + + if let onRetry { + ListRowButton(L10n.retry, action: onRetry) + .foregroundStyle(accentColor.overlayColor, accentColor) + .frame(maxWidth: 750) + } + } + } +} + +extension ErrorView { + + init(error: ErrorType) { + self.init( + error: error, + onRetry: nil + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/jellyflood tvOS/Components 2/LandscapePosterProgressBar.swift b/jellyflood tvOS/Components 2/LandscapePosterProgressBar.swift new file mode 100644 index 00000000..c66ca950 --- /dev/null +++ b/jellyflood tvOS/Components 2/LandscapePosterProgressBar.swift @@ -0,0 +1,46 @@ +// +// 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 LandscapePosterProgressBar: View { + + private let title: String? + private let progress: Double + + init(title: String? = nil, progress: Double) { + self.title = title + self.progress = progress + } + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.7), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 3) { + + if let title { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + } + + ProgressBar(progress: progress) + .frame(height: 5) + } + .padding(10) + } + } +} diff --git a/jellyflood tvOS/Components 2/ListRowButton.swift b/jellyflood tvOS/Components 2/ListRowButton.swift new file mode 100644 index 00000000..03aebb92 --- /dev/null +++ b/jellyflood tvOS/Components 2/ListRowButton.swift @@ -0,0 +1,77 @@ +// +// 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 + +// TODO: on focus, make the cancel and destructive style +// match style like in an `alert` +struct ListRowButton: View { + + // MARK: - Environment + + @Environment(\.isEnabled) + private var isEnabled + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Button Variables + + let title: String + let role: ButtonRole? + let action: () -> Void + + // MARK: - Initializer + + init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { + self.title = title + self.role = role + self.action = action + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(secondaryStyle) + .brightness(isFocused ? 0.25 : 0) + + Text(title) + .foregroundStyle(primaryStyle) + .font(.body.weight(.bold)) + } + } + .buttonStyle(.card) + .frame(maxHeight: 75) + .focused($isFocused) + } + + // MARK: - Primary Style + + private var primaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + // MARK: - Secondary Style + + private var secondaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red.opacity(0.2)) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + } +} diff --git a/jellyflood tvOS/Components 2/ListRowMenu.swift b/jellyflood tvOS/Components 2/ListRowMenu.swift new file mode 100644 index 00000000..b6800ce1 --- /dev/null +++ b/jellyflood tvOS/Components 2/ListRowMenu.swift @@ -0,0 +1,139 @@ +// +// 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 ListRowMenu: View { + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Properties + + private let title: Text + private let subtitle: Subtitle? + private let content: () -> Content + + // MARK: - Body + + var body: some View { + Menu(content: content) { + HStack { + title + .foregroundStyle(isFocused ? .black : .white) + .padding(.leading, 4) + + Spacer() + + if let subtitle { + subtitle + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + + Image(systemName: "chevron.up.chevron.down") + .font(.body.weight(.regular)) + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? Color.white : Color.clear) + ) + .scaleEffect(isFocused ? 1.04 : 1.0) + .animation(.easeInOut(duration: 0.125), value: isFocused) + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + .focused($isFocused) + } +} + +// MARK: - Initializers + +// Base initializer +extension ListRowMenu where Subtitle == Text? { + + init(_ title: Text, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = nil + self.content = content + } + + init(_ title: Text, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content + } + + init(_ title: Text, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = nil + self.content = content + } + + init(_ title: String, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle + self.content = content + } +} + +// Custom view subtitles +extension ListRowMenu { + + init(_ title: String, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle() + self.content = content + } + + init(_ title: Text, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle() + self.content = content + } +} + +// Initialize from a CaseIterable Enum +extension ListRowMenu where Subtitle == Text, Content == AnyView { + + init( + _ title: String, + selection: Binding + ) where ItemType: CaseIterable & Displayable & Hashable, + ItemType.AllCases: RandomAccessCollection + { + self.title = Text(title) + self.subtitle = Text(selection.wrappedValue.displayTitle) + self.content = { + Picker(title, selection: selection) { + ForEach(Array(ItemType.allCases), id: \.self) { option in + Text(option.displayTitle).tag(option) + } + } + .eraseToAnyView() + } + } +} diff --git a/jellyflood tvOS/Components 2/NonePosterButton.swift b/jellyflood tvOS/Components 2/NonePosterButton.swift new file mode 100644 index 00000000..e533275a --- /dev/null +++ b/jellyflood tvOS/Components 2/NonePosterButton.swift @@ -0,0 +1,37 @@ +// +// 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 NonePosterButton: View { + + let type: PosterDisplayType + + var body: some View { + Button { + ZStack { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "minus.circle") + .font(.title) + .foregroundColor(.secondary) + + L10n.none.text + .font(.title3) + .foregroundColor(.secondary) + } + } + .posterStyle(type) + } + } + .buttonStyle(.card) + } +} diff --git a/jellyflood tvOS/Components 2/OrderedSectionSelectorView.swift b/jellyflood tvOS/Components 2/OrderedSectionSelectorView.swift new file mode 100644 index 00000000..b5b23203 --- /dev/null +++ b/jellyflood tvOS/Components 2/OrderedSectionSelectorView.swift @@ -0,0 +1,198 @@ +// +// 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 SwiftUI + +struct OrderedSectionSelectorView: View { + + @Environment(\.editMode) + private var editMode + + @State + private var focusedElement: Element? + + @StateObject + private var selection: BindingBox<[Element]> + + private var disabledSelection: [Element] { + sources.filter { !selection.value.contains($0) } + } + + private var label: (Element) -> any View + private let sources: [Element] + private var systemImage: String + + private func move(from source: IndexSet, to destination: Int) { + selection.value.move(fromOffsets: source, toOffset: destination) + editMode?.wrappedValue = .inactive + } + + private func select(element: Element) { + if selection.value.contains(element) { + selection.value.removeAll(where: { $0 == element }) + } else { + selection.value.append(element) + } + } + + var body: some View { + NavigationStack { + SplitFormWindowView() + .descriptionView { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + List { + EnabledSection( + elements: $selection.value, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select, + move: move, + header: { + Group { + HStack { + Text(L10n.enabled) + Spacer() + if editMode?.wrappedValue.isEditing ?? false { + Button(L10n.done) { + withAnimation { + editMode?.wrappedValue = .inactive + } + } + } else { + Button(L10n.edit) { + withAnimation { + editMode?.wrappedValue = .active + } + } + } + } + } + } + ) + + DisabledSection( + elements: disabledSelection, + label: label, + isEditing: editMode?.wrappedValue.isEditing ?? false, + select: select + ) + } + .environment(\.editMode, editMode) + } + .animation(.linear(duration: 0.2), value: selection.value) + } + } +} + +private struct EnabledSection: View { + + @Binding + var elements: [Element] + + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + let move: (IndexSet, Int) -> Void + let header: () -> any View + + var body: some View { + Section { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + } header: { + header() + .eraseToAnyView() + } + } +} + +private struct DisabledSection: View { + + let elements: [Element] + let label: (Element) -> any View + let isEditing: Bool + let select: (Element) -> Void + + var body: some View { + Section(L10n.disabled) { + if elements.isEmpty { + Text(L10n.none) + .foregroundStyle(.secondary) + } + + ForEach(elements, id: \.self) { element in + Button { + if !isEditing { + select(element) + } + } label: { + HStack { + label(element) + .eraseToAnyView() + + Spacer() + + if !isEditing { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + } + } +} + +extension OrderedSectionSelectorView { + + init(selection: Binding<[Element]>, sources: [Element]) { + self._selection = StateObject(wrappedValue: BindingBox(source: selection)) + self.sources = sources + self.label = { Text($0.displayTitle).foregroundColor(.primary).eraseToAnyView() } + self.systemImage = "filemenu.and.selection" + } + + func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { + copy(modifying: \.label, with: content) + } + + func systemImage(_ systemName: String) -> Self { + copy(modifying: \.systemImage, with: systemName) + } +} diff --git a/jellyflood tvOS/Components 2/PosterButton.swift b/jellyflood tvOS/Components 2/PosterButton.swift new file mode 100644 index 00000000..604222d1 --- /dev/null +++ b/jellyflood tvOS/Components 2/PosterButton.swift @@ -0,0 +1,222 @@ +// +// 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 JellyfinAPI +import SwiftUI + +private let landscapeMaxWidth: CGFloat = 500 +private let portraitMaxWidth: CGFloat = 500 + +struct PosterButton: View { + + @EnvironmentTypeValue(\.posterOverlayRegistry) + private var posterOverlayRegistry + + @State + private var posterSize: CGSize = .zero + + private var horizontalAlignment: HorizontalAlignment + private let item: Item + private let type: PosterDisplayType + private let label: any View + private let action: () -> Void + + @ViewBuilder + private func poster(overlay: some View) -> some View { + PosterImage(item: item, type: type) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { overlay } + .contentShape(.contextMenuPreview, Rectangle()) + .posterStyle(type) + .posterShadow() + .hoverEffect(.highlight) + } + + var body: some View { + Button(action: action) { + let overlay = posterOverlayRegistry?(item) ?? + PosterButton.DefaultOverlay(item: item) + .eraseToAnyView() + + poster(overlay: overlay) + .trackingSize($posterSize) + + label + .eraseToAnyView() + } + .buttonStyle(.borderless) + .buttonBorderShape(.roundedRectangle) + .focusedValue(\.focusedPoster, AnyPoster(item)) + .accessibilityLabel(item.displayTitle) + .matchedContextMenu(for: item) { + EmptyView() + } + } +} + +extension PosterButton { + + init( + item: Item, + type: PosterDisplayType, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> any View + ) { + self.item = item + self.type = type + self.action = action + self.label = label() + self.horizontalAlignment = .leading + } + + func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { + copy(modifying: \.horizontalAlignment, with: alignment) + } +} + +// TODO: Shared default content with iOS? +// - check if content is generally same + +extension PosterButton { + + // MARK: Default Content + + struct TitleContentView: View { + + let item: Item + + var body: some View { + Text(item.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .accessibilityLabel(item.displayTitle) + } + } + + struct SubtitleContentView: View { + + let item: Item + + var body: some View { + Text(item.subtitle ?? "") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + } + + struct TitleSubtitleContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + TitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + + SubtitleContentView(item: item) + .lineLimit(1, reservesSpace: true) + } + } + } + + // TODO: clean up + + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + } + + Subtitle(item: item) + } + } + } + + struct Subtitle: View { + + let item: BaseItemDto + + var body: some View { + + SeparatorHStack { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, 3) + } content: { + SeparatorHStack { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle { + Text(item.displayTitle) + + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + // TODO: Find better way for these indicators, see EpisodeCard + struct DefaultOverlay: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let item: Item + + var body: some View { + ZStack { + if let item = item as? BaseItemDto { + if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { + WatchedIndicator(size: 45) + .isVisible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10) + .isVisible(showProgress) + } else if item.canBePlayed, !item.isLiveStream { + UnwatchedIndicator(size: 45) + .foregroundColor(.jellyfinPurple) + .isVisible(showUnplayed) + } + } + + if item.userData?.isFavorite == true { + FavoriteIndicator(size: 45) + .isVisible(showFavorited) + } + } + } + } + } +} diff --git a/jellyflood tvOS/Components 2/PosterHStack.swift b/jellyflood tvOS/Components 2/PosterHStack.swift new file mode 100644 index 00000000..e6441e20 --- /dev/null +++ b/jellyflood tvOS/Components 2/PosterHStack.swift @@ -0,0 +1,83 @@ +// +// 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 CollectionHStack +import SwiftUI + +// TODO: trailing content refactor? + +struct PosterHStack: View where Data.Element == Element, Data.Index == Int { + + private var data: Data + private var title: String? + private var type: PosterDisplayType + private var label: (Element) -> any View + private var trailingContent: () -> any View + private var action: (Element) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + if let title { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + Spacer() + } + } + + CollectionHStack( + uniqueElements: data, + columns: type == .landscape ? 4 : 7 + ) { item in + PosterButton( + item: item, + type: type + ) { + action(item) + } label: { + label(item).eraseToAnyView() + } + } + .clipsToBounds(false) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) + .itemSpacing(EdgeInsets.edgePadding - 20) + .scrollBehavior(.continuousLeadingEdge) + } + .focusSection() + } +} + +extension PosterHStack { + + init( + title: String? = nil, + type: PosterDisplayType, + items: Data, + action: @escaping (Element) -> Void, + @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } + ) { + self.init( + data: items, + title: title, + type: type, + label: label, + trailingContent: { EmptyView() }, + action: action + ) + } + + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} diff --git a/jellyflood tvOS/Components 2/SFSymbolButton.swift b/jellyflood tvOS/Components 2/SFSymbolButton.swift new file mode 100644 index 00000000..523307a2 --- /dev/null +++ b/jellyflood tvOS/Components 2/SFSymbolButton.swift @@ -0,0 +1,70 @@ +// +// 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 +import UIKit + +struct SFSymbolButton: UIViewRepresentable { + + private var onSelect: () -> Void + private let pointSize: CGFloat + private let systemName: String + private let systemNameFocused: String? + + private func makeButtonConfig(_ button: UIButton) { + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + + button.setImage(symbolImage, for: .normal) + + if let systemNameFocused { + let focusedSymbolImage = UIImage(systemName: systemNameFocused, withConfiguration: symbolImageConfig) + + button.setImage(focusedSymbolImage, for: .focused) + } + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { _ in + self.onSelect() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + makeButtonConfig(button) + + return button + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + makeButtonConfig(uiView) + } +} + +extension SFSymbolButton { + + init( + systemName: String, + systemNameFocused: String? = nil, + pointSize: CGFloat = 32 + ) { + self.init( + onSelect: {}, + pointSize: pointSize, + systemName: systemName, + systemNameFocused: systemNameFocused + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components 2/SeeAllPosterButton.swift b/jellyflood tvOS/Components 2/SeeAllPosterButton.swift new file mode 100644 index 00000000..11617446 --- /dev/null +++ b/jellyflood tvOS/Components 2/SeeAllPosterButton.swift @@ -0,0 +1,50 @@ +// +// 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 SeeAllPosterButton: View { + + private let type: PosterDisplayType + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) + + L10n.seeAll.text + .font(.title3) + } + } + .posterStyle(type) + } + .buttonStyle(.card) + } +} + +extension SeeAllPosterButton { + + init(type: PosterDisplayType) { + self.init( + type: type, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components 2/ServerButton.swift b/jellyflood tvOS/Components 2/ServerButton.swift new file mode 100644 index 00000000..4565e022 --- /dev/null +++ b/jellyflood tvOS/Components 2/ServerButton.swift @@ -0,0 +1,54 @@ +// +// 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 ServerButton: View { + + let server: SwiftfinStore.State.Server + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) + + Text(server.currentURL.absoluteString) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(10) + } + .buttonStyle(.card) + } +} + +extension ServerButton { + + init(server: SwiftfinStore.State.Server) { + self.server = server + self.onSelect = {} + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components 2/SliderContainer/SliderContainer.swift b/jellyflood tvOS/Components 2/SliderContainer/SliderContainer.swift new file mode 100644 index 00000000..0d6c29e3 --- /dev/null +++ b/jellyflood tvOS/Components 2/SliderContainer/SliderContainer.swift @@ -0,0 +1,202 @@ +// +// 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 SliderContainer: UIViewRepresentable { + + private var value: Binding + private let total: Value + private let onEditingChanged: (Bool) -> Void + private let view: AnyView + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + @ViewBuilder view: @escaping () -> some SliderContentView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = AnyView(view()) + } + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + view: AnyView + ) { + self.value = value + self.total = total + self.onEditingChanged = onEditingChanged + self.view = view + } + + func makeUIView(context: Context) -> UISliderContainer { + UISliderContainer( + value: value, + total: total, + onEditingChanged: onEditingChanged, + view: view + ) + } + + func updateUIView(_ uiView: UISliderContainer, context: Context) { + DispatchQueue.main.async { + uiView.containerState.value = value.wrappedValue + } + } +} + +final class UISliderContainer: UIControl { + + private let decelerationMaxVelocity: CGFloat = 1000.0 + private let fineTuningVelocityThreshold: CGFloat = 1000.0 + private let panDampingValue: CGFloat = 50 + + private let onEditingChanged: (Bool) -> Void + private let total: Value + private let valueBinding: Binding + + private var panGestureRecognizer: DirectionalPanGestureRecognizer! + private lazy var progressHostingController: UIHostingController = { + let hostingController = UIHostingController(rootView: AnyView(view.environmentObject(containerState))) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + return hostingController + }() + + private var progressHostingView: UIView { progressHostingController.view } + + let containerState: SliderContainerState + let view: AnyView + private var decelerationTimer: Timer? + + init( + value: Binding, + total: Value, + onEditingChanged: @escaping (Bool) -> Void, + view: AnyView + ) { + self.onEditingChanged = onEditingChanged + self.total = total + self.valueBinding = value + self.containerState = .init( + isEditing: false, + isFocused: false, + value: value.wrappedValue, + total: total + ) + self.view = view + super.init(frame: .zero) + + setupViews() + setupGestureRecognizer() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + addSubview(progressHostingView) + NSLayoutConstraint.activate([ + progressHostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressHostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + progressHostingView.topAnchor.constraint(equalTo: topAnchor), + progressHostingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func setupGestureRecognizer() { + panGestureRecognizer = DirectionalPanGestureRecognizer( + direction: .horizontal, + target: self, + action: #selector(didPan) + ) + addGestureRecognizer(panGestureRecognizer) + } + + private var panDeceleratingVelocity: CGFloat = 0 + private var panStartValue: Value = 0 + + @objc + private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { + let translation = gestureRecognizer.translation(in: self).x + let velocity = gestureRecognizer.velocity(in: self).x + + switch gestureRecognizer.state { + case .began: + onEditingChanged(true) + panStartValue = containerState.value + stopDeceleratingTimer() + case .changed: + let dampedTranslation = translation / panDampingValue + let newValue = panStartValue + Value(dampedTranslation) + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + + containerState.value = clampedValue + valueBinding.wrappedValue = clampedValue + case .ended, .cancelled: + panStartValue = containerState.value + + if abs(velocity) > fineTuningVelocityThreshold { + let direction: CGFloat = velocity > 0 ? 1 : -1 + panDeceleratingVelocity = (abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity) / + panDampingValue + decelerationTimer = Timer.scheduledTimer( + timeInterval: 0.01, + target: self, + selector: #selector(handleDeceleratingTimer), + userInfo: nil, + repeats: true + ) + } else { + onEditingChanged(false) + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func handleDeceleratingTimer(time: Timer) { + let newValue = panStartValue + Value(panDeceleratingVelocity) * 0.01 + let clampedValue = clamp(newValue, min: 0, max: containerState.total) + + sendActions(for: .valueChanged) + panStartValue = clampedValue + + panDeceleratingVelocity *= 0.92 + + if !isFocused || abs(panDeceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + valueBinding.wrappedValue = clampedValue + containerState.value = clampedValue + onEditingChanged(false) + } + + private func stopDeceleratingTimer() { + decelerationTimer?.invalidate() + decelerationTimer = nil + panDeceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + containerState.isFocused = (context.nextFocusedView == self) + } +} diff --git a/jellyflood tvOS/Components 2/SliderContainer/SliderContainerState.swift b/jellyflood tvOS/Components 2/SliderContainer/SliderContainerState.swift new file mode 100644 index 00000000..15f1a606 --- /dev/null +++ b/jellyflood tvOS/Components 2/SliderContainer/SliderContainerState.swift @@ -0,0 +1,33 @@ +// +// 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 Combine + +class SliderContainerState: ObservableObject { + + @Published + var isEditing: Bool + @Published + var isFocused: Bool + @Published + var value: Value + + let total: Value + + init( + isEditing: Bool, + isFocused: Bool, + value: Value, + total: Value + ) { + self.isEditing = isEditing + self.isFocused = isFocused + self.value = value + self.total = total + } +} diff --git a/jellyflood tvOS/Components 2/SliderContainer/SliderContentView.swift b/jellyflood tvOS/Components 2/SliderContainer/SliderContentView.swift new file mode 100644 index 00000000..0833f7d7 --- /dev/null +++ b/jellyflood tvOS/Components 2/SliderContainer/SliderContentView.swift @@ -0,0 +1,18 @@ +// +// 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 + +protocol SliderContentView: View { + + associatedtype Value: BinaryFloatingPoint + + /// The current state of the slider container. + /// Receive this object as an environment object. + var sliderState: SliderContainerState { get } +} diff --git a/jellyflood tvOS/Components 2/SplitFormWindowView.swift b/jellyflood tvOS/Components 2/SplitFormWindowView.swift new file mode 100644 index 00000000..e434030d --- /dev/null +++ b/jellyflood tvOS/Components 2/SplitFormWindowView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct SplitFormWindowView: View { + + private var contentView: () -> any View + private var descriptionView: () -> any View + + var body: some View { + HStack { + + descriptionView() + .eraseToAnyView() + .frame(maxWidth: .infinity) + + Form { + contentView() + .eraseToAnyView() + } + .padding(.top) + .scrollClipDisabled() + } + } +} + +extension SplitFormWindowView { + + init() { + self.init( + contentView: { EmptyView() }, + descriptionView: { Color.clear } + ) + } + + func contentView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.contentView, with: content) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/jellyflood tvOS/Components 2/SplitLoginWindowView.swift b/jellyflood tvOS/Components 2/SplitLoginWindowView.swift new file mode 100644 index 00000000..d4b5ca78 --- /dev/null +++ b/jellyflood tvOS/Components 2/SplitLoginWindowView.swift @@ -0,0 +1,78 @@ +// +// 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 Foundation +import SwiftUI + +struct SplitLoginWindowView: View { + + // MARK: - Loading State + + private let isLoading: Bool + + // MARK: - Content Variables + + private let leadingContentView: Leading + private let trailingContentView: Trailing + + // MARK: - Background Variable + + private let backgroundImageSource: ImageSource? + + // MARK: - Body + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 22) { + leadingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + + Divider() + .padding(.vertical, 100) + + VStack(alignment: .leading, spacing: 22) { + trailingContentView + } + .frame(maxWidth: .infinity) + .edgePadding(.vertical) + } + .navigationBarBranding(isLoading: isLoading) + .background { + if let backgroundImageSource { + ZStack { + ImageView(backgroundImageSource) + .aspectRatio(contentMode: .fill) + .id(backgroundImageSource) + .transition(.opacity) + .animation(.linear, value: backgroundImageSource) + + Color.black + .opacity(0.9) + } + .ignoresSafeArea() + } + } + } +} + +extension SplitLoginWindowView { + + init( + isLoading: Bool = false, + backgroundImageSource: ImageSource? = nil, + @ViewBuilder leadingContentView: @escaping () -> Leading, + @ViewBuilder trailingContentView: @escaping () -> Trailing + ) { + self.backgroundImageSource = backgroundImageSource + self.isLoading = isLoading + self.leadingContentView = leadingContentView() + self.trailingContentView = trailingContentView() + } +} diff --git a/jellyflood tvOS/Components 2/StepperView.swift b/jellyflood tvOS/Components 2/StepperView.swift new file mode 100644 index 00000000..3c7492d2 --- /dev/null +++ b/jellyflood tvOS/Components 2/StepperView.swift @@ -0,0 +1,114 @@ +// +// 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 StepperView: View { + + @Binding + private var value: Value + + @State + private var updatedValue: Value + @Environment(\.presentationMode) + private var presentationMode + + private var title: String + private var description: String? + private var range: ClosedRange + private let step: Value.Stride + private var formatter: (Value) -> String + private var onCloseSelected: () -> Void + + var body: some View { + VStack { + VStack { + Spacer() + + Text(title) + .font(.title) + .fontWeight(.semibold) + + if let description { + Text(description) + .padding(.vertical) + } + } + .frame(maxHeight: .infinity) + + formatter(updatedValue).text + .font(.title) + .frame(height: 250) + + VStack { + + HStack { + Button { + if updatedValue > range.lowerBound { + updatedValue = max(updatedValue.advanced(by: -step), range.lowerBound) + value = updatedValue + } + } label: { + Image(systemName: "minus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + + Button { + if updatedValue < range.upperBound { + updatedValue = min(updatedValue.advanced(by: step), range.upperBound) + value = updatedValue + } + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + } + + Button(L10n.close) { + onCloseSelected() + presentationMode.wrappedValue.dismiss() + } + + Spacer() + } + .frame(maxHeight: .infinity) + } + } +} + +extension StepperView { + + init( + title: String, + description: String? = nil, + value: Binding, + range: ClosedRange, + step: Value.Stride + ) { + self._value = value + self._updatedValue = State(initialValue: value.wrappedValue) + self.title = title + self.description = description + self.range = range + self.step = step + self.formatter = { $0.description } + self.onCloseSelected = {} + } + + func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { + copy(modifying: \.formatter, with: formatter) + } + + func onCloseSelected(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onCloseSelected, with: action) + } +} diff --git a/jellypig tvOS/Components/CinematicBackgroundView.swift b/jellyflood tvOS/Components/CinematicBackgroundView.swift similarity index 100% rename from jellypig tvOS/Components/CinematicBackgroundView.swift rename to jellyflood tvOS/Components/CinematicBackgroundView.swift diff --git a/jellypig tvOS/Components/CinematicItemSelector.swift b/jellyflood tvOS/Components/CinematicItemSelector.swift similarity index 100% rename from jellypig tvOS/Components/CinematicItemSelector.swift rename to jellyflood tvOS/Components/CinematicItemSelector.swift diff --git a/jellypig tvOS/Components/DotHStack.swift b/jellyflood tvOS/Components/DotHStack.swift similarity index 100% rename from jellypig tvOS/Components/DotHStack.swift rename to jellyflood tvOS/Components/DotHStack.swift diff --git a/jellyflood tvOS/Components/EnumPickerView.swift b/jellyflood tvOS/Components/EnumPickerView.swift new file mode 100644 index 00000000..87640f8a --- /dev/null +++ b/jellyflood tvOS/Components/EnumPickerView.swift @@ -0,0 +1,60 @@ +// +// 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 EnumPickerView: View { + + @Binding + private var selection: EnumType + + private var descriptionView: () -> any View + private var title: String? + + var body: some View { + SplitFormWindowView() + .descriptionView(descriptionView) + .contentView { + Section { + ForEach(EnumType.allCases.asArray, id: \.hashValue) { item in + Button { + selection = item + } label: { + HStack { + Text(item.displayTitle) + + Spacer() + + if selection == item { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } + } +} + +extension EnumPickerView { + + init( + title: String? = nil, + selection: Binding + ) { + self.init( + selection: selection, + descriptionView: { EmptyView() }, + title: title + ) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/jellyflood tvOS/Components/ErrorView.swift b/jellyflood tvOS/Components/ErrorView.swift new file mode 100644 index 00000000..d3bceda8 --- /dev/null +++ b/jellyflood tvOS/Components/ErrorView.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView: View { + + @Default(.accentColor) + private var accentColor + + private let error: ErrorType + private var onRetry: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 150)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(minWidth: 250, maxWidth: 750) + .multilineTextAlignment(.center) + + if let onRetry { + ListRowButton(L10n.retry, action: onRetry) + .foregroundStyle(accentColor.overlayColor, accentColor) + .frame(maxWidth: 750) + } + } + } +} + +extension ErrorView { + + init(error: ErrorType) { + self.init( + error: error, + onRetry: nil + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/jellyflood tvOS/Components/LandscapePosterProgressBar.swift b/jellyflood tvOS/Components/LandscapePosterProgressBar.swift new file mode 100644 index 00000000..c66ca950 --- /dev/null +++ b/jellyflood tvOS/Components/LandscapePosterProgressBar.swift @@ -0,0 +1,46 @@ +// +// 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 LandscapePosterProgressBar: View { + + private let title: String? + private let progress: Double + + init(title: String? = nil, progress: Double) { + self.title = title + self.progress = progress + } + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.7), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 3) { + + if let title { + Text(title) + .font(.subheadline) + .foregroundColor(.white) + } + + ProgressBar(progress: progress) + .frame(height: 5) + } + .padding(10) + } + } +} diff --git a/jellyflood tvOS/Components/ListRowButton.swift b/jellyflood tvOS/Components/ListRowButton.swift new file mode 100644 index 00000000..03aebb92 --- /dev/null +++ b/jellyflood tvOS/Components/ListRowButton.swift @@ -0,0 +1,77 @@ +// +// 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 + +// TODO: on focus, make the cancel and destructive style +// match style like in an `alert` +struct ListRowButton: View { + + // MARK: - Environment + + @Environment(\.isEnabled) + private var isEnabled + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Button Variables + + let title: String + let role: ButtonRole? + let action: () -> Void + + // MARK: - Initializer + + init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { + self.title = title + self.role = role + self.action = action + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(secondaryStyle) + .brightness(isFocused ? 0.25 : 0) + + Text(title) + .foregroundStyle(primaryStyle) + .font(.body.weight(.bold)) + } + } + .buttonStyle(.card) + .frame(maxHeight: 75) + .focused($isFocused) + } + + // MARK: - Primary Style + + private var primaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + // MARK: - Secondary Style + + private var secondaryStyle: some ShapeStyle { + if role == .destructive || role == .cancel { + return AnyShapeStyle(Color.red.opacity(0.2)) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + } +} diff --git a/jellyflood tvOS/Components/ListRowMenu.swift b/jellyflood tvOS/Components/ListRowMenu.swift new file mode 100644 index 00000000..b6800ce1 --- /dev/null +++ b/jellyflood tvOS/Components/ListRowMenu.swift @@ -0,0 +1,139 @@ +// +// 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 ListRowMenu: View { + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + // MARK: - Properties + + private let title: Text + private let subtitle: Subtitle? + private let content: () -> Content + + // MARK: - Body + + var body: some View { + Menu(content: content) { + HStack { + title + .foregroundStyle(isFocused ? .black : .white) + .padding(.leading, 4) + + Spacer() + + if let subtitle { + subtitle + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + + Image(systemName: "chevron.up.chevron.down") + .font(.body.weight(.regular)) + .foregroundStyle(isFocused ? .black : .secondary) + .brightness(isFocused ? 0.4 : 0) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isFocused ? Color.white : Color.clear) + ) + .scaleEffect(isFocused ? 1.04 : 1.0) + .animation(.easeInOut(duration: 0.125), value: isFocused) + } + .menuStyle(.borderlessButton) + .listRowInsets(.zero) + .focused($isFocused) + } +} + +// MARK: - Initializers + +// Base initializer +extension ListRowMenu where Subtitle == Text? { + + init(_ title: Text, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = nil + self.content = content + } + + init(_ title: Text, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content + } + + init(_ title: Text, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = nil + self.content = content + } + + init(_ title: String, subtitle: String?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle.map { Text($0) } + self.content = content + } + + init(_ title: String, subtitle: Text?, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle + self.content = content + } +} + +// Custom view subtitles +extension ListRowMenu { + + init(_ title: String, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = Text(title) + self.subtitle = subtitle() + self.content = content + } + + init(_ title: Text, @ViewBuilder subtitle: @escaping () -> Subtitle, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle() + self.content = content + } +} + +// Initialize from a CaseIterable Enum +extension ListRowMenu where Subtitle == Text, Content == AnyView { + + init( + _ title: String, + selection: Binding + ) where ItemType: CaseIterable & Displayable & Hashable, + ItemType.AllCases: RandomAccessCollection + { + self.title = Text(title) + self.subtitle = Text(selection.wrappedValue.displayTitle) + self.content = { + Picker(title, selection: selection) { + ForEach(Array(ItemType.allCases), id: \.self) { option in + Text(option.displayTitle).tag(option) + } + } + .eraseToAnyView() + } + } +} diff --git a/jellyflood tvOS/Components/NonePosterButton.swift b/jellyflood tvOS/Components/NonePosterButton.swift new file mode 100644 index 00000000..e533275a --- /dev/null +++ b/jellyflood tvOS/Components/NonePosterButton.swift @@ -0,0 +1,37 @@ +// +// 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 NonePosterButton: View { + + let type: PosterDisplayType + + var body: some View { + Button { + ZStack { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "minus.circle") + .font(.title) + .foregroundColor(.secondary) + + L10n.none.text + .font(.title3) + .foregroundColor(.secondary) + } + } + .posterStyle(type) + } + } + .buttonStyle(.card) + } +} diff --git a/jellypig tvOS/Components/OrderedSectionSelectorView.swift b/jellyflood tvOS/Components/OrderedSectionSelectorView.swift similarity index 100% rename from jellypig tvOS/Components/OrderedSectionSelectorView.swift rename to jellyflood tvOS/Components/OrderedSectionSelectorView.swift diff --git a/jellypig tvOS/Components/PosterButton.swift b/jellyflood tvOS/Components/PosterButton.swift similarity index 100% rename from jellypig tvOS/Components/PosterButton.swift rename to jellyflood tvOS/Components/PosterButton.swift diff --git a/jellypig tvOS/Components/PosterHStack.swift b/jellyflood tvOS/Components/PosterHStack.swift similarity index 100% rename from jellypig tvOS/Components/PosterHStack.swift rename to jellyflood tvOS/Components/PosterHStack.swift diff --git a/jellyflood tvOS/Components/SFSymbolButton.swift b/jellyflood tvOS/Components/SFSymbolButton.swift new file mode 100644 index 00000000..523307a2 --- /dev/null +++ b/jellyflood tvOS/Components/SFSymbolButton.swift @@ -0,0 +1,70 @@ +// +// 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 +import UIKit + +struct SFSymbolButton: UIViewRepresentable { + + private var onSelect: () -> Void + private let pointSize: CGFloat + private let systemName: String + private let systemNameFocused: String? + + private func makeButtonConfig(_ button: UIButton) { + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + + button.setImage(symbolImage, for: .normal) + + if let systemNameFocused { + let focusedSymbolImage = UIImage(systemName: systemNameFocused, withConfiguration: symbolImageConfig) + + button.setImage(focusedSymbolImage, for: .focused) + } + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { _ in + self.onSelect() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + makeButtonConfig(button) + + return button + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + makeButtonConfig(uiView) + } +} + +extension SFSymbolButton { + + init( + systemName: String, + systemNameFocused: String? = nil, + pointSize: CGFloat = 32 + ) { + self.init( + onSelect: {}, + pointSize: pointSize, + systemName: systemName, + systemNameFocused: systemNameFocused + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components/SeeAllPosterButton.swift b/jellyflood tvOS/Components/SeeAllPosterButton.swift new file mode 100644 index 00000000..11617446 --- /dev/null +++ b/jellyflood tvOS/Components/SeeAllPosterButton.swift @@ -0,0 +1,50 @@ +// +// 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 SeeAllPosterButton: View { + + private let type: PosterDisplayType + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) + + L10n.seeAll.text + .font(.title3) + } + } + .posterStyle(type) + } + .buttonStyle(.card) + } +} + +extension SeeAllPosterButton { + + init(type: PosterDisplayType) { + self.init( + type: type, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellyflood tvOS/Components/ServerButton.swift b/jellyflood tvOS/Components/ServerButton.swift new file mode 100644 index 00000000..4565e022 --- /dev/null +++ b/jellyflood tvOS/Components/ServerButton.swift @@ -0,0 +1,54 @@ +// +// 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 ServerButton: View { + + let server: SwiftfinStore.State.Server + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) + + Text(server.currentURL.absoluteString) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(10) + } + .buttonStyle(.card) + } +} + +extension ServerButton { + + init(server: SwiftfinStore.State.Server) { + self.server = server + self.onSelect = {} + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Components/SplitFormWindowView.swift b/jellyflood tvOS/Components/SplitFormWindowView.swift similarity index 100% rename from jellypig tvOS/Components/SplitFormWindowView.swift rename to jellyflood tvOS/Components/SplitFormWindowView.swift diff --git a/jellypig tvOS/Components/SplitLoginWindowView.swift b/jellyflood tvOS/Components/SplitLoginWindowView.swift similarity index 83% rename from jellypig tvOS/Components/SplitLoginWindowView.swift rename to jellyflood tvOS/Components/SplitLoginWindowView.swift index dfdc7e91..560517d1 100644 --- a/jellypig tvOS/Components/SplitLoginWindowView.swift +++ b/jellyflood tvOS/Components/SplitLoginWindowView.swift @@ -30,28 +30,34 @@ struct SplitLoginWindowView: View { var body: some View { HStack(alignment: .top) { - VStack(alignment: .leading) { - Section(leadingTitle) { - VStack(alignment: .leading) { + VStack(alignment: .center) { + Section { + VStack(alignment: .center) { leadingContentView() .eraseToAnyView() } .frame(maxWidth: .infinity) .padding(.vertical) + } header: { + Text(leadingTitle) + .frame(maxWidth: .infinity, alignment: .center) } } Divider() .padding(.vertical, 100) - VStack(alignment: .leading) { - Section(trailingTitle) { - VStack(alignment: .leading) { + VStack(alignment: .center) { + Section { + VStack(alignment: .center) { trailingContentView() .eraseToAnyView() } .frame(maxWidth: .infinity) .padding(.vertical) + } header: { + Text(trailingTitle) + .frame(maxWidth: .infinity, alignment: .center) } } } diff --git a/jellyflood tvOS/Components/StepperView.swift b/jellyflood tvOS/Components/StepperView.swift new file mode 100644 index 00000000..3c7492d2 --- /dev/null +++ b/jellyflood tvOS/Components/StepperView.swift @@ -0,0 +1,114 @@ +// +// 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 StepperView: View { + + @Binding + private var value: Value + + @State + private var updatedValue: Value + @Environment(\.presentationMode) + private var presentationMode + + private var title: String + private var description: String? + private var range: ClosedRange + private let step: Value.Stride + private var formatter: (Value) -> String + private var onCloseSelected: () -> Void + + var body: some View { + VStack { + VStack { + Spacer() + + Text(title) + .font(.title) + .fontWeight(.semibold) + + if let description { + Text(description) + .padding(.vertical) + } + } + .frame(maxHeight: .infinity) + + formatter(updatedValue).text + .font(.title) + .frame(height: 250) + + VStack { + + HStack { + Button { + if updatedValue > range.lowerBound { + updatedValue = max(updatedValue.advanced(by: -step), range.lowerBound) + value = updatedValue + } + } label: { + Image(systemName: "minus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + + Button { + if updatedValue < range.upperBound { + updatedValue = min(updatedValue.advanced(by: step), range.upperBound) + value = updatedValue + } + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + } + + Button(L10n.close) { + onCloseSelected() + presentationMode.wrappedValue.dismiss() + } + + Spacer() + } + .frame(maxHeight: .infinity) + } + } +} + +extension StepperView { + + init( + title: String, + description: String? = nil, + value: Binding, + range: ClosedRange, + step: Value.Stride + ) { + self._value = value + self._updatedValue = State(initialValue: value.wrappedValue) + self.title = title + self.description = description + self.range = range + self.step = step + self.formatter = { $0.description } + self.onCloseSelected = {} + } + + func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { + copy(modifying: \.formatter, with: formatter) + } + + func onCloseSelected(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onCloseSelected, with: action) + } +} diff --git a/jellyflood tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift b/jellyflood tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift new file mode 100644 index 00000000..75318c5f --- /dev/null +++ b/jellyflood tvOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift @@ -0,0 +1,34 @@ +// +// 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 + +struct NavigationBarBrandingModifier: ViewModifier { + + let isLoading: Bool + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .principal) { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .padding(.bottom, 25) + } + + if isLoading { + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } + } + } +} diff --git a/jellypig tvOS/Extensions/View/View-tvOS.swift b/jellyflood tvOS/Extensions/View/View-tvOS 2.swift similarity index 100% rename from jellypig tvOS/Extensions/View/View-tvOS.swift rename to jellyflood tvOS/Extensions/View/View-tvOS 2.swift diff --git a/jellyflood tvOS/Extensions/View/View-tvOS.swift b/jellyflood tvOS/Extensions/View/View-tvOS.swift new file mode 100644 index 00000000..233da0c3 --- /dev/null +++ b/jellyflood tvOS/Extensions/View/View-tvOS.swift @@ -0,0 +1,25 @@ +// +// 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 +import SwiftUIIntrospect + +extension View { + + @ViewBuilder + func navigationBarBranding( + isLoading: Bool = false + ) -> some View { + modifier( + NavigationBarBrandingModifier( + isLoading: isLoading + ) + ) + } +} diff --git a/jellypig tvOS/ImageButtonStyle.swift b/jellyflood tvOS/ImageButtonStyle 2.swift similarity index 100% rename from jellypig tvOS/ImageButtonStyle.swift rename to jellyflood tvOS/ImageButtonStyle 2.swift diff --git a/jellyflood tvOS/ImageButtonStyle.swift b/jellyflood tvOS/ImageButtonStyle.swift new file mode 100644 index 00000000..20256444 --- /dev/null +++ b/jellyflood tvOS/ImageButtonStyle.swift @@ -0,0 +1,21 @@ +// +// 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 +// + +struct ImageButtonStyle: ButtonStyle { + + let focused: Bool + func makeBody(configuration: Configuration) -> some View { + configuration + .label + .padding(6) + .foregroundColor(Color.white) + .background(Color.blue) + .cornerRadius(100) + .shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0 + } +} diff --git a/jellyflood tvOS/Objects 2/FocusGuide.swift b/jellyflood tvOS/Objects 2/FocusGuide.swift new file mode 100644 index 00000000..2b449cd5 --- /dev/null +++ b/jellyflood tvOS/Objects 2/FocusGuide.swift @@ -0,0 +1,153 @@ +// +// 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 FocusGuideModifier: ViewModifier { + + @FocusState + var focusDirection: FocusDirection? + @EnvironmentObject + var focusGuide: FocusGuide + + let focusConstructor: FocusConstructor + let onContentFocus: (() -> Void)? + + let debug = false + + func body(content: Content) -> some View { + VStack(spacing: 0) { + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.topTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .top) + + HStack(spacing: 0) { + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.leftTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .left) + + content + .focused($focusDirection, equals: .content) + + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.rightTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .right) + } + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.bottomTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .bottom) + } + .onChange(of: focusDirection) { _, focusDirection in + guard let focusDirection = focusDirection else { return } + switch focusDirection { + case .top: + focusGuide.transition(to: focusConstructor.topTarget!) + case .bottom: + focusGuide.transition(to: focusConstructor.bottomTarget!) + case .left: + focusGuide.transition(to: focusConstructor.leftTarget!) + case .right: + focusGuide.transition(to: focusConstructor.rightTarget!) + case .content: () + } + } + .onChange(of: focusGuide.focusedTag) { _, newTag in + if newTag == focusConstructor.tag { + if let onContentFocus { + onContentFocus() + } else { + focusDirection = .content + } + } + } + } +} + +extension View { + func focusGuide( + _ focusGuide: FocusGuide, + tag: String, + onContentFocus: (() -> Void)? = nil, + top: String? = nil, + bottom: String? = nil, + left: String? = nil, + right: String? = nil + ) -> some View { + let focusConstructor = FocusConstructor( + tag: tag, + topTarget: top, + bottomTarget: bottom, + leftTarget: left, + rightTarget: right + ) + return modifier(FocusGuideModifier(focusConstructor: focusConstructor, onContentFocus: onContentFocus)) + .environmentObject(focusGuide) + } +} + +enum FocusDirection: String { + case top + case bottom + case content + case left + case right +} + +struct FocusConstructor { + + let tag: String + let topTarget: String? + let bottomTarget: String? + let leftTarget: String? + let rightTarget: String? + + init( + tag: String, + topTarget: String?, + bottomTarget: String?, + leftTarget: String?, + rightTarget: String? + ) { + self.tag = tag + self.topTarget = topTarget + self.bottomTarget = bottomTarget + self.leftTarget = leftTarget + self.rightTarget = rightTarget + } +} + +// TODO: generic focus values instead of strings +// TODO: keep mapping of all tag connections, +// only add complete connections + +class FocusGuide: ObservableObject { + + @Published + private(set) var focusedTag: String? + + private(set) var lastFocusedTag: String? + + func transition(to tag: String?) { + lastFocusedTag = focusedTag + focusedTag = tag + } +} diff --git a/jellypig tvOS/Objects/FocusGuide.swift b/jellyflood tvOS/Objects/FocusGuide.swift similarity index 100% rename from jellypig tvOS/Objects/FocusGuide.swift rename to jellyflood tvOS/Objects/FocusGuide.swift diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..7b0faedf --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "1280x768-back.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.png new file mode 100644 index 00000000..ea6be913 Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.png differ diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 00000000..16acd385 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,16 @@ +{ + "layers" : [ + { + "filename" : "Back.png", + "type" : "back" + }, + { + "filename" : "Middle.png", + "type" : "middle" + }, + { + "filename" : "Front.png", + "type" : "front" + } + ] +} \ No newline at end of file diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..dc3e9968 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "512.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.png new file mode 100644 index 00000000..27ccc6c7 Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.png differ diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.png new file mode 100644 index 00000000..4a736d4a Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..e1178b2e --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "400x240-back.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Webp.net-resizeimage.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.png new file mode 100644 index 00000000..cbdb849a Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.png differ diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 00000000..16acd385 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,16 @@ +{ + "layers" : [ + { + "filename" : "Back.png", + "type" : "back" + }, + { + "filename" : "Middle.png", + "type" : "middle" + }, + { + "filename" : "Front.png", + "type" : "front" + } + ] +} \ No newline at end of file diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 00000000..597613ac --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "216.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Webp.net-resizeimage-2.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.png new file mode 100644 index 00000000..6119eb55 Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.png differ diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.png new file mode 100644 index 00000000..154092e3 Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.png differ diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 00000000..f47ba43d --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -0,0 +1,32 @@ +{ + "assets" : [ + { + "filename" : "App Icon - App Store.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "1280x768" + }, + { + "filename" : "App Icon.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "400x240" + }, + { + "filename" : "Top Shelf Image Wide.imageset", + "idiom" : "tv", + "role" : "top-shelf-image-wide", + "size" : "2320x720" + }, + { + "filename" : "Top Shelf Image.imageset", + "idiom" : "tv", + "role" : "top-shelf-image", + "size" : "1920x720" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 00000000..d4b5af42 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,28 @@ +{ + "images" : [ + { + "filename" : "top shelf.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Untitled-1.png", + "idiom" : "tv", + "scale" : "2x" + }, + { + "filename" : "top shelf-1.png", + "idiom" : "tv-marketing", + "scale" : "1x" + }, + { + "filename" : "Untitled-2.png", + "idiom" : "tv-marketing", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf.png new file mode 100644 index 00000000..6c321fdd Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 00000000..21e50b6b --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,28 @@ +{ + "images" : [ + { + "filename" : "top shelf.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Untitled-2.png", + "idiom" : "tv", + "scale" : "2x" + }, + { + "filename" : "top shelf-1.png", + "idiom" : "tv-marketing", + "scale" : "1x" + }, + { + "filename" : "Untitled-1.png", + "idiom" : "tv-marketing", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png new file mode 100644 index 00000000..2ab3eacf Binary files /dev/null and b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png differ diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png diff --git a/jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png b/jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png rename to jellyflood tvOS/Resources/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png diff --git a/jellyflood tvOS/Resources/Assets.xcassets/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json new file mode 100644 index 00000000..9d8a820a --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chrome.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg new file mode 100644 index 00000000..fab308dc --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg @@ -0,0 +1 @@ +Google Chrome icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json new file mode 100644 index 00000000..5029adef --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "edge.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg new file mode 100644 index 00000000..8a552924 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json new file mode 100644 index 00000000..1913d0ce --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "edgechromium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg new file mode 100644 index 00000000..14d68a5d --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json new file mode 100644 index 00000000..59e17e67 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "firefox.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg new file mode 100644 index 00000000..7f468b3f --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg @@ -0,0 +1 @@ +Mozilla Firefox icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json new file mode 100644 index 00000000..83f2b872 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "html5.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg new file mode 100644 index 00000000..63704799 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg @@ -0,0 +1 @@ +HTML5 icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json new file mode 100644 index 00000000..2b8e8f69 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "msie.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg new file mode 100644 index 00000000..f5b362d7 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg @@ -0,0 +1 @@ +Internet Explorer icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json new file mode 100644 index 00000000..e73067c2 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "opera.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg new file mode 100644 index 00000000..dd57f924 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg @@ -0,0 +1 @@ +Opera icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json new file mode 100644 index 00000000..feaf2495 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "safari.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg new file mode 100644 index 00000000..12abbb95 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg @@ -0,0 +1 @@ +safari icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json new file mode 100644 index 00000000..26c167e3 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "android.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg new file mode 100644 index 00000000..24edc8bb --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg @@ -0,0 +1,4 @@ + + + + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json new file mode 100644 index 00000000..a011cbd8 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "apple.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg new file mode 100644 index 00000000..4477a452 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg @@ -0,0 +1 @@ +Apple diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json new file mode 100644 index 00000000..35feb3d5 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "finamp.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg new file mode 100644 index 00000000..8bd3a90c --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg @@ -0,0 +1,7 @@ + + Finamp icon + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json new file mode 100644 index 00000000..99d22c49 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "kodi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg new file mode 100644 index 00000000..3618149b --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json new file mode 100644 index 00000000..31c0ff53 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "playstation.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg new file mode 100644 index 00000000..c6595340 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg @@ -0,0 +1 @@ +PlayStation icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json new file mode 100644 index 00000000..8ea8fc43 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "roku.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg new file mode 100644 index 00000000..eb1e621b --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg @@ -0,0 +1,7 @@ + + Roku icon + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json new file mode 100644 index 00000000..cf998a9c --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "samsungtv.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg new file mode 100644 index 00000000..afdd19e2 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg @@ -0,0 +1 @@ +Samsung icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json new file mode 100644 index 00000000..63b3674d --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "webOS.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg new file mode 100644 index 00000000..611ba963 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json new file mode 100644 index 00000000..e6f157d1 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "windows.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg new file mode 100644 index 00000000..531e72e1 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg @@ -0,0 +1 @@ +Windows icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json new file mode 100644 index 00000000..847c1b55 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "xbox.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg new file mode 100644 index 00000000..640dd34a --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg @@ -0,0 +1 @@ +Xbox icon diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json new file mode 100644 index 00000000..41d3d101 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "home-assistant.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg new file mode 100644 index 00000000..a34be98d --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg @@ -0,0 +1 @@ + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json new file mode 100644 index 00000000..44a54246 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "other.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg new file mode 100644 index 00000000..91e1d9e2 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg @@ -0,0 +1 @@ + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json new file mode 100644 index 00000000..25a4a3a2 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jellyfin-blob.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/jellypig tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/jellyflood tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg similarity index 100% rename from jellypig tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg rename to jellyflood tvOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg diff --git a/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json new file mode 100644 index 00000000..3d67d001 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "tomato.fresh.svg", + "idiom" : "universal" + } + ] +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg b/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg new file mode 100644 index 00000000..d84fcc47 --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg @@ -0,0 +1,108 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from tomato.fresh + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json b/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json new file mode 100644 index 00000000..4539290b --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "tomato.rotten.svg", + "idiom" : "universal" + } + ] +} diff --git a/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg b/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg new file mode 100644 index 00000000..47e742da --- /dev/null +++ b/jellyflood tvOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg @@ -0,0 +1,97 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from tomato.rotten + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jellyflood tvOS/Resources/Info.plist b/jellyflood tvOS/Resources/Info.plist new file mode 100644 index 00000000..264c6b20 --- /dev/null +++ b/jellyflood tvOS/Resources/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundledisplayTitle + Jellyfin + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIDesignRequiresCompatibility + + UILaunchScreen + + UIColorName + LaunchScreenBackground + + UIRequiredDeviceCapabilities + + arm64 + + UIUserInterfaceStyle + Dark + + diff --git a/jellyflood tvOS/Views 2/AppLoadingView.swift b/jellyflood tvOS/Views 2/AppLoadingView.swift new file mode 100644 index 00000000..21ccb3fc --- /dev/null +++ b/jellyflood tvOS/Views 2/AppLoadingView.swift @@ -0,0 +1,39 @@ +// +// 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 + +/// The loading view for the app when migrations are taking place +struct AppLoadingView: View { + + @State + private var didFailMigration = false + + var body: some View { + ZStack { + Color.clear + + if !didFailMigration { + ProgressView() + } + + if didFailMigration { + ErrorView(error: JellyfinAPIError("An internal error occurred.")) + } + } + .topBarTrailing { + Button(L10n.advanced, systemImage: "gearshape.fill") {} + .foregroundStyle(.secondary) + .disabled(true) + .opacity(didFailMigration ? 0 : 1) + } + .onNotification(.didFailMigration) { _ in + didFailMigration = true + } + } +} diff --git a/jellyflood tvOS/Views 2/AppSettingsView/AppSettingsView.swift b/jellyflood tvOS/Views 2/AppSettingsView/AppSettingsView.swift new file mode 100644 index 00000000..8eee997d --- /dev/null +++ b/jellyflood tvOS/Views 2/AppSettingsView/AppSettingsView.swift @@ -0,0 +1,96 @@ +// +// 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 + +struct AppSettingsView: View { + + @Default(.selectUserUseSplashscreen) + private var selectUserUseSplashscreen + @Default(.selectUserAllServersSplashscreen) + private var selectUserAllServersSplashscreen + + @Default(.appAppearance) + private var appearance + + @Router + private var router + + @StateObject + private var viewModel = SettingsViewModel() + + @State + private var resetUserSettingsSelected: Bool = false + @State + private var removeAllServersSelected: Bool = false + + private var selectedServer: ServerState? { + viewModel.servers.first { server in + selectUserAllServersSplashscreen == .server(id: server.id) + } + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(.jellyfinBlobBlue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + LabeledContent( + L10n.version, + value: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" + ) + + Section { + + Toggle(L10n.useSplashscreen, isOn: $selectUserUseSplashscreen) + + if selectUserUseSplashscreen { + ListRowMenu(L10n.servers) { + if selectUserAllServersSplashscreen == .all { + Label(L10n.random, systemImage: "dice.fill") + } else if let selectedServer { + Text(selectedServer.name) + } else { + Text(L10n.none) + } + } content: { + Picker(L10n.servers, selection: $selectUserAllServersSplashscreen) { + Label(L10n.random, systemImage: "dice.fill") + .tag(SelectUserServerSelection.all) + + ForEach(viewModel.servers) { server in + Text(server.name) + .tag(SelectUserServerSelection.server(id: server.id)) + } + } + } + } + } header: { + Text(L10n.splashscreen) + } footer: { + if selectUserUseSplashscreen { + Text(L10n.splashscreenFooter) + } + } + + SignOutIntervalSection() + + ChevronButton(L10n.logs) { + router.route(to: .log) + } + } + .navigationTitle(L10n.advanced) + } +} diff --git a/jellyflood tvOS/Views 2/AppSettingsView/Components/HourMinutePicker.swift b/jellyflood tvOS/Views 2/AppSettingsView/Components/HourMinutePicker.swift new file mode 100644 index 00000000..553949df --- /dev/null +++ b/jellyflood tvOS/Views 2/AppSettingsView/Components/HourMinutePicker.swift @@ -0,0 +1,102 @@ +// +// 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 +import TVOSPicker + +struct HourMinutePicker: UIViewRepresentable { + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + + func makeUIView(context: Context) -> some UIView { + let picker = TVOSPickerView( + style: .default // pass custom style here if needed + ) + + context.coordinator.add(picker: picker) + + // Defaults doesn't provide a binding so utilize a callback + context.coordinator.callback = { interval in + backgroundSignOutInterval = interval + } + + return picker + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(previousInterval: backgroundSignOutInterval) + } + + class Coordinator: TVOSPickerViewDelegate { + // callback to set the value to defaults + var callback: ((TimeInterval) -> Void)? + + // selected values + private var selectedHour: TimeInterval = 0 + private var selectedMinute: TimeInterval = 0 + + // previousInterval helps set the default values of the picker + private let previousInterval: TimeInterval + + init(previousInterval: TimeInterval) { + self.previousInterval = previousInterval + } + + func add(picker: TVOSPickerView) { + picker.delegate = self + } + + func numberOfComponents(in pickerView: TVOSPickerView) -> Int { + // number of components (columns) + 2 + } + + func pickerView(_ pickerView: TVOSPickerView, numberOfRowsInComponent component: Int) -> Int { + // number of rows in each component + if component == 0 { + 24 // hours + } else { + 60 // mintues + } + } + + func pickerView(_ pickerView: TVOSPickerView, titleForRow row: Int, inComponent component: Int) -> String? { + // string to display in each row + if component == 0 { + "\(row) \(L10n.hours)" + } else { + "\(row) \(L10n.minutes)" + } + } + + func pickerView(_ pickerView: TVOSPickerView, didSelectRow row: Int, inComponent component: Int) { + // update state with the newly selected row + + if component == 0 { + selectedHour = Double(row * 3600) + } else { + selectedMinute = Double(row * 60) + } + + callback?(selectedHour + selectedMinute) + } + + func indexOfSelectedRow(inComponent component: Int, ofPickerView pickerView: TVOSPickerView) -> Int? { + // provide an index of selected row - used as initially focused index as well as after each reloadData + if component == 0 { + Int(previousInterval) / 3600 // select the previous hour + } else { + (Int(previousInterval) / 60) % 60 // select the previous minute + } + } + } +} diff --git a/jellyflood tvOS/Views 2/AppSettingsView/Components/SignOutIntervalSection.swift b/jellyflood tvOS/Views 2/AppSettingsView/Components/SignOutIntervalSection.swift new file mode 100644 index 00000000..d420f972 --- /dev/null +++ b/jellyflood tvOS/Views 2/AppSettingsView/Components/SignOutIntervalSection.swift @@ -0,0 +1,54 @@ +// +// 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 AppSettingsView { + + struct SignOutIntervalSection: View { + + @Router + private var router + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + @Default(.signOutOnBackground) + private var signOutOnBackground + @Default(.signOutOnClose) + private var signOutOnClose + + @State + private var isEditingBackgroundSignOutInterval: Bool = false + + var body: some View { + Section { + Toggle(L10n.signoutClose, isOn: $signOutOnClose) + } footer: { + Text(L10n.signoutCloseFooter) + } + + Section { + Toggle(L10n.signoutBackground, isOn: $signOutOnBackground) + + if signOutOnBackground { + ChevronButton( + L10n.duration, + subtitle: Text(backgroundSignOutInterval, format: .hourMinute) + ) { + router.route(to: .hourPicker) + } + } + } footer: { + Text( + L10n.signoutBackgroundFooter + ) + } + } + } +} diff --git a/jellyflood tvOS/Views 2/FontPickerView.swift b/jellyflood tvOS/Views 2/FontPickerView.swift new file mode 100644 index 00000000..2e9d4889 --- /dev/null +++ b/jellyflood tvOS/Views 2/FontPickerView.swift @@ -0,0 +1,53 @@ +// +// 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 + +struct FontPickerView: View { + + @Binding + private var selection: String + + @State + private var updateSelection: String + + init(selection: Binding) { + self._selection = selection + self.updateSelection = selection.wrappedValue + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "character.textbox") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + ForEach(UIFont.familyNames, id: \.self) { fontFamily in + Button { + selection = fontFamily + updateSelection = fontFamily + } label: { + HStack { + Text(fontFamily) + .font(.custom(fontFamily, size: 28)) + + Spacer() + + if updateSelection == fontFamily { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } +} diff --git a/jellyflood tvOS/Views 2/ItemOverviewView.swift b/jellyflood tvOS/Views 2/ItemOverviewView.swift new file mode 100644 index 00000000..8cd80faf --- /dev/null +++ b/jellyflood tvOS/Views 2/ItemOverviewView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ItemOverviewView: View { + + let item: BaseItemDto + + @ViewBuilder + private var content: some View { + GeometryReader { proxy in + VStack(alignment: .center) { + + Text(item.displayTitle) + .font(.title) + .frame(maxHeight: proxy.size.height * 0.33) + + VStack(alignment: .leading, spacing: 20) { + if let tagline = item.taglines?.first { + Text(tagline) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + } + + if let overview = item.overview { + Text(overview) + } + } + } + .padding(.horizontal, 100) + } + } + + var body: some View { + ZStack { + BlurView() + + content + } + .ignoresSafeArea() + } +} diff --git a/jellyflood tvOS/Views 2/LearnMoreModal.swift b/jellyflood tvOS/Views 2/LearnMoreModal.swift new file mode 100644 index 00000000..287ec47d --- /dev/null +++ b/jellyflood tvOS/Views 2/LearnMoreModal.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct LearnMoreModal: View { + + private let content: AnyView + + // MARK: - Initializer + + init(@LabeledContentBuilder content: () -> AnyView) { + self.content = content() + } + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + content + .labeledContentStyle(LearnMoreLabeledContentStyle()) + .foregroundStyle(Color.primary, Color.secondary) + } + .padding(24) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(Material.regular) + } + .padding() + } +} diff --git a/jellyflood tvOS/Views 2/MediaSourceInfoView.swift b/jellyflood tvOS/Views 2/MediaSourceInfoView.swift new file mode 100644 index 00000000..8be39959 --- /dev/null +++ b/jellyflood tvOS/Views 2/MediaSourceInfoView.swift @@ -0,0 +1,136 @@ +// +// 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 + +struct MediaSourceInfoView: View { + + @FocusState + private var selectedMediaStream: MediaStream? + + @State + private var lastSelectedMediaStream: MediaStream? + + let source: MediaSourceInfo + + @ViewBuilder + private var content: some View { + GeometryReader { proxy in + VStack(alignment: .center) { + + Text(source.displayTitle) + .font(.title) + .frame(maxHeight: proxy.size.height * 0.33) + + HStack { + Form { + if let videoStreams = source.videoStreams, + videoStreams.isNotEmpty + { + Section(L10n.video) { + ForEach(videoStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + + if let audioStreams = source.audioStreams, + audioStreams.isNotEmpty + { + Section(L10n.audio) { + ForEach(audioStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + + if let subtitleStreams = source.subtitleStreams, + subtitleStreams.isNotEmpty + { + Section(L10n.subtitle) { + ForEach(subtitleStreams, id: \.self) { stream in + Button { + Text(stream.displayTitle ?? .emptyDash) + } + .focused($selectedMediaStream, equals: stream) + } + } + } + } + + Form { + if let lastSelectedMediaStream { + Section { + ForEach(lastSelectedMediaStream.metadataProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + + if lastSelectedMediaStream.colorProperties.isNotEmpty { + Section(L10n.color) { + ForEach(lastSelectedMediaStream.colorProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + } + + if lastSelectedMediaStream.deliveryProperties.isNotEmpty { + Section(L10n.delivery) { + ForEach(lastSelectedMediaStream.deliveryProperties, id: \.label) { property in + Button { + LabeledContent( + property.label, + value: property.value + ) + } + } + } + } + } else { + Button { + L10n.none.text + } + } + } + } + .padding(.horizontal) + } + .frame(maxWidth: .infinity) + } + .onChange(of: selectedMediaStream) { _, newValue in + guard let newValue else { return } + lastSelectedMediaStream = newValue + } + } + + var body: some View { + ZStack { + BlurView() + + content + } + .ignoresSafeArea() + } +} diff --git a/jellypig tvOS/Views/MediaView/Components/MediaItem.swift b/jellyflood tvOS/Views 2/MediaView/Components/MediaItem.swift similarity index 100% rename from jellypig tvOS/Views/MediaView/Components/MediaItem.swift rename to jellyflood tvOS/Views 2/MediaView/Components/MediaItem.swift diff --git a/jellyflood tvOS/Views 2/MediaView/MediaView.swift b/jellyflood tvOS/Views 2/MediaView/MediaView.swift new file mode 100644 index 00000000..2d3cbc2a --- /dev/null +++ b/jellyflood tvOS/Views 2/MediaView/MediaView.swift @@ -0,0 +1,77 @@ +// +// 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 CollectionVGrid +import Defaults +import JellyfinAPI +import Stinsen +import SwiftUI + +struct MediaView: View { + + @EnvironmentObject + private var router: MediaCoordinator.Router + + @StateObject + private var viewModel = MediaViewModel() + + @ViewBuilder + private var contentView: some View { + CollectionVGrid( + uniqueElements: viewModel.mediaItems, + layout: .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) + .onSelect { + switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) + case .downloads: + assertionFailure("Downloads unavailable on tvOS") + case .favorites: + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + id: "favorites", + filters: .favorites + ) + router.route(to: \.library, viewModel) + case .liveTV: + router.route(to: \.liveTV) + } + } + } + } + + var body: some View { + ZStack { + // This keeps the ErrorView vertically aligned with the PagingLibraryView + Color.clear + + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .onFirstAppear { + viewModel.send(.refresh) + } + } +} diff --git a/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramButtonContent.swift b/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramButtonContent.swift new file mode 100644 index 00000000..d6e325d5 --- /dev/null +++ b/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramButtonContent.swift @@ -0,0 +1,51 @@ +// +// 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 ProgramsView { + + struct ProgramButtonContent: View { + + let program: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + + Text(program.channelName ?? .emptyDash) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + Text(program.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(1, reservesSpace: true) + + HStack(spacing: 2) { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + Text("-") + + if let endDate = program.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramProgressOverlay.swift b/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramProgressOverlay.swift new file mode 100644 index 00000000..7c5ad24a --- /dev/null +++ b/jellyflood tvOS/Views 2/ProgramsView/Components/ProgramProgressOverlay.swift @@ -0,0 +1,37 @@ +// +// 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 ProgramsView { + + struct ProgramProgressOverlay: View { + + @State + private var programProgress: Double = 0.0 + + let program: BaseItemDto + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + WrappedView { + if let startDate = program.startDate, startDate < Date.now { + LandscapePosterProgressBar( + progress: program.programProgress ?? 0 + ) + } + } + .onReceive(timer) { newValue in + if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { + programProgress = newValue.timeIntervalSince(startDate) / duration + } + } + } + } +} diff --git a/jellyflood tvOS/Views 2/ProgramsView/ProgramsView.swift b/jellyflood tvOS/Views 2/ProgramsView/ProgramsView.swift new file mode 100644 index 00000000..b7d8c506 --- /dev/null +++ b/jellyflood tvOS/Views 2/ProgramsView/ProgramsView.swift @@ -0,0 +1,104 @@ +// +// 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 + +// TODO: background refresh for programs with timer? + +// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program + +struct ProgramsView: View { + + @Router + private var router + + @StateObject + private var programsViewModel = ProgramsViewModel() + + @ViewBuilder + private var contentView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if programsViewModel.recommended.isNotEmpty { + programsSection(title: L10n.onNow, keyPath: \.recommended) + } + + if programsViewModel.series.isNotEmpty { + programsSection(title: L10n.series, keyPath: \.series) + } + + if programsViewModel.movies.isNotEmpty { + programsSection(title: L10n.movies, keyPath: \.movies) + } + + if programsViewModel.kids.isNotEmpty { + programsSection(title: L10n.kids, keyPath: \.kids) + } + + if programsViewModel.sports.isNotEmpty { + programsSection(title: L10n.sports, keyPath: \.sports) + } + + if programsViewModel.news.isNotEmpty { + programsSection(title: L10n.news, keyPath: \.news) + } + } + } + } + + @ViewBuilder + private func programsSection( + title: String, + keyPath: KeyPath + ) -> some View { + PosterHStack( + title: title, + type: .landscape, + items: programsViewModel[keyPath: keyPath] + ) { _ in +// guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } +// router.route( +// to: \.liveVideoPlayer, +// LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) +// ) + } label: { + ProgramButtonContent(program: $0) + } + .posterOverlay(for: BaseItemDto.self) { + ProgramProgressOverlay(program: $0) + } + } + + var body: some View { + ZStack { + switch programsViewModel.state { + case .content: + if programsViewModel.hasNoResults { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + programsViewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: programsViewModel.state) + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + if programsViewModel.state == .initial { + programsViewModel.send(.refresh) + } + } + } +} diff --git a/jellyflood tvOS/Views 2/QuickConnectView.swift b/jellyflood tvOS/Views 2/QuickConnectView.swift new file mode 100644 index 00000000..09fcc78a --- /dev/null +++ b/jellyflood tvOS/Views 2/QuickConnectView.swift @@ -0,0 +1,78 @@ +// +// 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 + +struct QuickConnectView: View { + + @Router + private var router + + @ObservedObject + private var viewModel: QuickConnect + + init(quickConnect: QuickConnect) { + self.viewModel = quickConnect + } + + private func pollingView(code: String) -> some View { + VStack(spacing: 20) { + BulletedList(spacing: 16) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(code) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .edgePadding() + } + + var body: some View { + ZStack { + switch viewModel.state { + case .authenticated, .idle, .retrievingCode: + ProgressView() + case let .polling(code): + pollingView(code: code) + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.start() + } + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .edgePadding() + .navigationTitle(L10n.quickConnect) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismiss() + } + #endif + .onFirstAppear { + viewModel.start() + } + .onDisappear { + viewModel.stop() + } + } +} diff --git a/jellyflood tvOS/Views 2/SearchView.swift b/jellyflood tvOS/Views 2/SearchView.swift new file mode 100644 index 00000000..81a6751c --- /dev/null +++ b/jellyflood tvOS/Views 2/SearchView.swift @@ -0,0 +1,199 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct SearchView: View { + + @Default(.Customization.searchPosterType) + private var searchPosterType + + @Router + private var router + + @State + private var searchQuery = "" + + @StateObject + private var viewModel = SearchViewModel() + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.search(query: searchQuery) + } + } + + @ViewBuilder + private var suggestionsView: some View { + VStack(spacing: 20) { + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var resultsView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + if let movies = viewModel.items[.movie], movies.isNotEmpty { + itemsSection( + title: L10n.movies, + type: .movie, + items: movies, + posterType: searchPosterType + ) + } + + if let series = viewModel.items[.series], series.isNotEmpty { + itemsSection( + title: L10n.tvShows, + type: .series, + items: series, + posterType: searchPosterType + ) + } + + if let collections = viewModel.items[.boxSet], collections.isNotEmpty { + itemsSection( + title: L10n.collections, + type: .boxSet, + items: collections, + posterType: searchPosterType + ) + } + + if let episodes = viewModel.items[.episode], episodes.isNotEmpty { + itemsSection( + title: L10n.episodes, + type: .episode, + items: episodes, + posterType: searchPosterType + ) + } + + if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty { + itemsSection( + title: L10n.musicVideos, + type: .musicVideo, + items: musicVideos, + posterType: .landscape + ) + } + + if let videos = viewModel.items[.video], videos.isNotEmpty { + itemsSection( + title: L10n.videos, + type: .video, + items: videos, + posterType: .landscape + ) + } + + if let programs = viewModel.items[.program], programs.isNotEmpty { + itemsSection( + title: L10n.programs, + type: .program, + items: programs, + posterType: .landscape + ) + } + + if let channels = viewModel.items[.tvChannel], channels.isNotEmpty { + itemsSection( + title: L10n.channels, + type: .tvChannel, + items: channels, + posterType: .square + ) + } + + if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty { + itemsSection( + title: L10n.artists, + type: .musicArtist, + items: musicArtists, + posterType: .portrait + ) + } + + if let people = viewModel.items[.person], people.isNotEmpty { + itemsSection( + title: L10n.people, + type: .person, + items: people, + posterType: .portrait + ) + } + } + .edgePadding(.vertical) + } + } + + private func select(_ item: BaseItemDto) { + switch item.type { + case .program, .tvChannel: + let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession) + router.route(to: .videoPlayer(provider: provider)) + default: + router.route(to: .item(item: item)) + } + } + + @ViewBuilder + private func itemsSection( + title: String, + type: BaseItemKind, + items: [BaseItemDto], + posterType: PosterDisplayType + ) -> some View { + PosterHStack( + title: title, + type: posterType, + items: items, + action: select + ) + } + + var body: some View { + ZStack { + switch viewModel.state { + case .error: + viewModel.error.map { errorView(with: $0) } + case .initial: + if viewModel.hasNoResults { + if searchQuery.isEmpty { + suggestionsView + } else { + Text(L10n.noResults) + } + } else { + resultsView + } + case .searching: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea(edges: [.bottom, .horizontal]) + .onFirstAppear { + viewModel.getSuggestions() + } + .onChange(of: searchQuery) { _, newValue in + viewModel.search(query: newValue) + } + .searchable(text: $searchQuery, prompt: L10n.search) + } +} diff --git a/jellyflood tvOS/Views 2/ServerDetailView.swift b/jellyflood tvOS/Views 2/ServerDetailView.swift new file mode 100644 index 00000000..0c487a92 --- /dev/null +++ b/jellyflood tvOS/Views 2/ServerDetailView.swift @@ -0,0 +1,109 @@ +// +// 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 + +struct EditServerView: View { + + @Router + private var router + + @Environment(\.isEditing) + private var isEditing + + @State + private var isPresentingConfirmDeletion: Bool = false + + @StateObject + private var viewModel: ServerConnectionViewModel + + init(server: ServerState) { + self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "server.rack") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section(L10n.server) { + LabeledContent( + L10n.name, + value: viewModel.server.name + ) + .focusable(false) + + if let serverVerion = StoredValues[.Server.publicInfo(id: viewModel.server.id)].version { + LabeledContent( + L10n.version, + value: serverVerion + ) + .focusable(false) + } + } + + Section { + ListRowMenu(L10n.serverURL, subtitle: viewModel.server.currentURL.absoluteString) { + ForEach(viewModel.server.urls.sorted(using: \.absoluteString), id: \.self) { url in + Button { + guard viewModel.server.currentURL != url else { return } + viewModel.setCurrentURL(to: url) + } label: { + HStack { + Text(url.absoluteString) + .foregroundColor(.primary) + + Spacer() + + if viewModel.server.currentURL == url { + Image(systemName: "checkmark") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + } + } + } + } header: { + L10n.url.text + } footer: { + if !viewModel.server.isVersionCompatible { + Label( + L10n.serverVersionWarning(JellyfinClient.sdkVersion.majorMinor.description), + systemImage: "exclamationmark.circle.fill" + ) + } + } + + if isEditing { + Section { + ListRowButton(L10n.delete, role: .destructive) { + isPresentingConfirmDeletion = true + } + .listRowBackground(Color.clear) + .listRowInsets(.zero) + } + } + } + .navigationTitle(L10n.server) + .alert(L10n.deleteServer, isPresented: $isPresentingConfirmDeletion) { + Button(L10n.delete, role: .destructive) { + viewModel.delete() +// router.popLast() + } + } message: { + Text(L10n.confirmDeleteServerAndUsers(viewModel.server.name)) + } + } +} diff --git a/jellyflood tvOS/Views 2/VideoPlayer/Components/LoadingView.swift b/jellyflood tvOS/Views 2/VideoPlayer/Components/LoadingView.swift new file mode 100644 index 00000000..4f5de256 --- /dev/null +++ b/jellyflood tvOS/Views 2/VideoPlayer/Components/LoadingView.swift @@ -0,0 +1,44 @@ +// +// 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 VideoPlayer { + + struct LoadingView: View { + + @Router + private var router + + var body: some View { + ZStack { + Color.black + + VStack(spacing: 10) { + + Text(L10n.retrievingMediaInformation) + .foregroundColor(.white) + + ProgressView() + + Button { + router.dismiss() + } label: { + Text(L10n.cancel) + .foregroundColor(.red) + .padding() + .overlay { + Capsule() + .stroke(Color.red, lineWidth: 1) + } + } + } + } + } + } +} diff --git a/jellypig tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveNativeVideoPlayer.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveNativeVideoPlayer.swift diff --git a/jellypig tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift diff --git a/jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveMainOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveMainOverlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveOverlays/LiveOverlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/LiveVideoPlayer.swift b/jellyflood tvOS/Views 2/VideoPlayer/LiveVideoPlayer.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/LiveVideoPlayer.swift rename to jellyflood tvOS/Views 2/VideoPlayer/LiveVideoPlayer.swift diff --git a/jellypig tvOS/Views/VideoPlayer/NativeVideoPlayer.swift b/jellyflood tvOS/Views 2/VideoPlayer/NativeVideoPlayer.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/NativeVideoPlayer.swift rename to jellyflood tvOS/Views 2/VideoPlayer/NativeVideoPlayer.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/ChapterOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/ChapterOverlay.swift diff --git a/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift new file mode 100644 index 00000000..92d42d12 --- /dev/null +++ b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift @@ -0,0 +1,15 @@ +// +// 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 FocusedValues { + + @Entry + var focusedPoster: AnyPoster? +} diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/BarActionButtons.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/BarActionButtons.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/BottomBarView.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/BottomBarView.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift diff --git a/jellyflood tvOS/Views 2/VideoPlayer/Overlays/ConfirmCloseOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/ConfirmCloseOverlay.swift new file mode 100644 index 00000000..803f58c2 --- /dev/null +++ b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/ConfirmCloseOverlay.swift @@ -0,0 +1,28 @@ +// +// 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 VideoPlayer.PlaybackControls { + + struct ConfirmCloseOverlay: View { + + var body: some View { + ZStack { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 96)) + .padding(3) + .background { + Circle() + .fill(Color.black.opacity(0.4)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + } +} diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/MainOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/MainOverlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/Overlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Overlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/Overlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/jellyflood tvOS/Views 2/VideoPlayer/Overlays/SmallMenuOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift rename to jellyflood tvOS/Views 2/VideoPlayer/Overlays/SmallMenuOverlay.swift diff --git a/jellypig tvOS/Views/VideoPlayer/VideoPlayer.swift b/jellyflood tvOS/Views 2/VideoPlayer/VideoPlayer.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/VideoPlayer.swift rename to jellyflood tvOS/Views 2/VideoPlayer/VideoPlayer.swift diff --git a/jellyflood tvOS/Views/AppLoadingView.swift b/jellyflood tvOS/Views/AppLoadingView.swift new file mode 100644 index 00000000..21ccb3fc --- /dev/null +++ b/jellyflood tvOS/Views/AppLoadingView.swift @@ -0,0 +1,39 @@ +// +// 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 + +/// The loading view for the app when migrations are taking place +struct AppLoadingView: View { + + @State + private var didFailMigration = false + + var body: some View { + ZStack { + Color.clear + + if !didFailMigration { + ProgressView() + } + + if didFailMigration { + ErrorView(error: JellyfinAPIError("An internal error occurred.")) + } + } + .topBarTrailing { + Button(L10n.advanced, systemImage: "gearshape.fill") {} + .foregroundStyle(.secondary) + .disabled(true) + .opacity(didFailMigration ? 0 : 1) + } + .onNotification(.didFailMigration) { _ in + didFailMigration = true + } + } +} diff --git a/jellypig tvOS/Views/AppSettingsView/AppSettingsView.swift b/jellyflood tvOS/Views/AppSettingsView/AppSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/AppSettingsView/AppSettingsView.swift rename to jellyflood tvOS/Views/AppSettingsView/AppSettingsView.swift diff --git a/jellyflood tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift b/jellyflood tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift new file mode 100644 index 00000000..553949df --- /dev/null +++ b/jellyflood tvOS/Views/AppSettingsView/Components/HourMinutePicker.swift @@ -0,0 +1,102 @@ +// +// 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 +import TVOSPicker + +struct HourMinutePicker: UIViewRepresentable { + + @Default(.backgroundSignOutInterval) + private var backgroundSignOutInterval + + func makeUIView(context: Context) -> some UIView { + let picker = TVOSPickerView( + style: .default // pass custom style here if needed + ) + + context.coordinator.add(picker: picker) + + // Defaults doesn't provide a binding so utilize a callback + context.coordinator.callback = { interval in + backgroundSignOutInterval = interval + } + + return picker + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(previousInterval: backgroundSignOutInterval) + } + + class Coordinator: TVOSPickerViewDelegate { + // callback to set the value to defaults + var callback: ((TimeInterval) -> Void)? + + // selected values + private var selectedHour: TimeInterval = 0 + private var selectedMinute: TimeInterval = 0 + + // previousInterval helps set the default values of the picker + private let previousInterval: TimeInterval + + init(previousInterval: TimeInterval) { + self.previousInterval = previousInterval + } + + func add(picker: TVOSPickerView) { + picker.delegate = self + } + + func numberOfComponents(in pickerView: TVOSPickerView) -> Int { + // number of components (columns) + 2 + } + + func pickerView(_ pickerView: TVOSPickerView, numberOfRowsInComponent component: Int) -> Int { + // number of rows in each component + if component == 0 { + 24 // hours + } else { + 60 // mintues + } + } + + func pickerView(_ pickerView: TVOSPickerView, titleForRow row: Int, inComponent component: Int) -> String? { + // string to display in each row + if component == 0 { + "\(row) \(L10n.hours)" + } else { + "\(row) \(L10n.minutes)" + } + } + + func pickerView(_ pickerView: TVOSPickerView, didSelectRow row: Int, inComponent component: Int) { + // update state with the newly selected row + + if component == 0 { + selectedHour = Double(row * 3600) + } else { + selectedMinute = Double(row * 60) + } + + callback?(selectedHour + selectedMinute) + } + + func indexOfSelectedRow(inComponent component: Int, ofPickerView pickerView: TVOSPickerView) -> Int? { + // provide an index of selected row - used as initially focused index as well as after each reloadData + if component == 0 { + Int(previousInterval) / 3600 // select the previous hour + } else { + (Int(previousInterval) / 60) % 60 // select the previous minute + } + } + } +} diff --git a/jellypig tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift b/jellyflood tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift similarity index 100% rename from jellypig tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift rename to jellyflood tvOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift diff --git a/jellyflood tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/jellyflood tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift new file mode 100644 index 00000000..fb732f96 --- /dev/null +++ b/jellyflood tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -0,0 +1,111 @@ +// +// 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 CollectionVGrid +import Foundation +import JellyfinAPI +import Logging +import SwiftUI + +struct ChannelLibraryView: View { + + @EnvironmentObject + private var router: VideoPlayerWrapperCoordinator.Router + + @StateObject + private var viewModel = ChannelLibraryViewModel() + + @State + private var errorMessage: String? + + private let logger = Logger(label: "ChannelLibraryView") + + @ViewBuilder + private var contentView: some View { + CollectionVGrid( + uniqueElements: viewModel.elements, + layout: .columns(3, insets: .init(0), itemSpacing: 25, lineSpacing: 25) + ) { channel in + WideChannelGridItem(channel: channel) + .onSelect { + print("🔴 CHANNEL CLICKED: \(channel.displayTitle)") + logger.info("Channel clicked: \(channel.displayTitle)") + logger.info("MediaSources count: \(channel.channel.mediaSources?.count ?? 0)") + + guard let mediaSource = channel.channel.mediaSources?.first else { + let error = "No media source available for channel '\(channel.displayTitle)'" + print("🔴 ERROR: \(error)") + logger.error("\(error)") + errorMessage = error + return + } + + print("🔴 MediaSource path: \(mediaSource.path ?? "nil")") + print("🔴 MediaSource transcodingURL: \(mediaSource.transcodingURL ?? "nil")") + logger.info("MediaSource ID: \(mediaSource.id ?? "nil")") + logger.info("MediaSource path: \(mediaSource.path ?? "nil")") + logger.info("MediaSource transcodingURL: \(mediaSource.transcodingURL ?? "nil")") + logger.info("MediaSource supportsDirectPlay: \(mediaSource.isSupportsDirectPlay ?? false)") + logger.info("MediaSource container: \(mediaSource.container ?? "nil")") + logger.info("Routing to live video player...") + print("🔴 ROUTING TO LIVE VIDEO PLAYER NOW") + + router.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) + ) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + } + + var body: some View { + ZStack { + Color(red: 0.15, green: 0.05, blue: 0.1) + .ignoresSafeArea() + + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .alert("Channel Playback Error", isPresented: .constant(errorMessage != nil), presenting: errorMessage) { _ in + Button("OK") { + errorMessage = nil + } + } message: { message in + Text(message) + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } + .sinceLastDisappear { interval in + // refresh after 3 hours + if interval >= 10800 { + viewModel.send(.refresh) + } + } + } +} diff --git a/jellypig tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/jellyflood tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift similarity index 100% rename from jellypig tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift rename to jellyflood tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift diff --git a/jellypig tvOS/Views/ConnectToServerView/Components/LocalServerButton.swift b/jellyflood tvOS/Views/ConnectToServerView/Components/LocalServerButton.swift similarity index 100% rename from jellypig tvOS/Views/ConnectToServerView/Components/LocalServerButton.swift rename to jellyflood tvOS/Views/ConnectToServerView/Components/LocalServerButton.swift diff --git a/jellypig tvOS/Views/ConnectToServerView/ConnectToServerView.swift b/jellyflood tvOS/Views/ConnectToServerView/ConnectToServerView.swift similarity index 100% rename from jellypig tvOS/Views/ConnectToServerView/ConnectToServerView.swift rename to jellyflood tvOS/Views/ConnectToServerView/ConnectToServerView.swift diff --git a/jellyflood tvOS/Views/ConnectToServerView/ConnectToXtreamView.swift b/jellyflood tvOS/Views/ConnectToServerView/ConnectToXtreamView.swift new file mode 100644 index 00000000..9b693c79 --- /dev/null +++ b/jellyflood tvOS/Views/ConnectToServerView/ConnectToXtreamView.swift @@ -0,0 +1,209 @@ +// +// 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 Combine +import Defaults +import SwiftUI + +struct ConnectToXtreamView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + @Default(.xtreamServers) + private var savedServers + + // MARK: - Focus Fields + + @FocusState + private var focusedField: Field? + + enum Field { + case name + case url + case username + case password + } + + // MARK: - State & Environment Objects + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + @StateObject + private var viewModel = ConnectToXtreamViewModel() + + // MARK: - Connect to Xtream Variables + + @State + private var name: String = "" + @State + private var url: String = "" + @State + private var username: String = "" + @State + private var password: String = "" + + // MARK: - Error States + + @State + private var error: Error? = nil + + // MARK: - Connect Section + + @ViewBuilder + private var connectSection: some View { + TextField(L10n.name, text: $name) + .disableAutocorrection(true) + .textInputAutocapitalization(.words) + .focused($focusedField, equals: .name) + + TextField("Server URL", text: $url) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .focused($focusedField, equals: .url) + + TextField("Username", text: $username) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .focused($focusedField, equals: .username) + + SecureField("Password", text: $password) + .focused($focusedField, equals: .password) + + if viewModel.state == .connecting || viewModel.state == .testing { + ListRowButton(L10n.cancel) { + viewModel.send(.cancel) + } + .foregroundStyle(.red, accentColor) + .padding(.vertical) + } else { + ListRowButton(L10n.connect) { + focusedField = nil + viewModel.send(.connect(name: name, url: url, username: username, password: password)) + } + .disabled(url.isEmpty || username.isEmpty || password.isEmpty) + .foregroundStyle( + accentColor.overlayColor, + (url.isEmpty || username.isEmpty || password.isEmpty) ? Color.white.opacity(0.5) : accentColor + ) + .opacity((url.isEmpty || username.isEmpty || password.isEmpty) ? 0.5 : 1) + .padding(.vertical) + } + } + + // MARK: - Saved Servers Section + + @ViewBuilder + private var savedServersSection: some View { + if savedServers.isEmpty { + Text("No saved Xtream servers") + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible()), count: 1), + spacing: 30 + ) { + ForEach(savedServers) { server in + XtreamServerButton(server: server) { + // Select this server + Defaults[.currentXtreamServerID] = server.id + router.popLast() + } + .environment( + \.isEnabled, + viewModel.state != .connecting && viewModel.state != .testing + ) + } + } + } + } + + // MARK: - Body + + var body: some View { + SplitLoginWindowView( + isLoading: viewModel.state == .connecting || viewModel.state == .testing, + leadingTitle: "Connect to Xtream Server", + trailingTitle: "Saved Servers" + ) { + connectSection + } trailingContentView: { + savedServersSection + } + .onFirstAppear { + focusedField = .url + } + .onReceive(viewModel.events) { event in + switch event { + case let .connected(server): + viewModel.saveServer(server) + Defaults[.currentXtreamServerID] = server.id + router.popLast() + case let .error(eventError): + error = eventError + focusedField = .url + } + } + .errorMessage($error) + } +} + +// MARK: - Xtream Server Button + +struct XtreamServerButton: View { + + @Default(.accentColor) + private var accentColor + + let server: XtreamServer + let action: () -> Void + + @Environment(\.isEnabled) + private var isEnabled + + var body: some View { + Button { + action() + } label: { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text(server.name) + .font(.headline) + .foregroundColor(.primary) + + Text(server.url.absoluteString) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + + Text("User: \(server.username)") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "tv") + .font(.title) + .foregroundColor(accentColor) + } + .padding() + .background(Color.gray.opacity(0.2)) + .cornerRadius(10) + } + .buttonStyle(.card) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.5) + } +} diff --git a/jellyflood tvOS/Views/ConnectToServerView/DualServerConnectView.swift b/jellyflood tvOS/Views/ConnectToServerView/DualServerConnectView.swift new file mode 100644 index 00000000..11b43b89 --- /dev/null +++ b/jellyflood tvOS/Views/ConnectToServerView/DualServerConnectView.swift @@ -0,0 +1,79 @@ +// +// 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 + +/// Dual-block view for connecting to either Jellyfin or Xtream servers +struct DualServerConnectView: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: SelectUserCoordinator.Router + + var body: some View { + SplitLoginWindowView( + leadingTitle: "Jellyfin Server", + trailingTitle: "Xtream Codes Server" + ) { + jellyfinBlock + } trailingContentView: { + xtreamBlock + } + .navigationTitle("Add Server") + .padding(.top, 80) + } + + @ViewBuilder + private var jellyfinBlock: some View { + VStack(spacing: 30) { + Image(systemName: "server.rack") + .font(.system(size: 100)) + .foregroundColor(accentColor) + .padding(.bottom, 10) + + Text("Connect to your Jellyfin server for movies, TV shows, and media library") + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + ListRowButton("Add Jellyfin Server") { + router.route(to: \.connectToServer) + } + .foregroundStyle(accentColor.overlayColor, accentColor) + .padding(.vertical, 20) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var xtreamBlock: some View { + VStack(spacing: 30) { + Image(systemName: "tv") + .font(.system(size: 100)) + .foregroundColor(accentColor) + .padding(.bottom, 10) + + Text("Connect to your Xtream Codes provider for Live TV channels and EPG") + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + ListRowButton("Add Xtream Server") { + router.route(to: \.connectToXtream) + } + .foregroundStyle(accentColor.overlayColor, accentColor) + .padding(.vertical, 20) + } + .frame(maxWidth: .infinity) + } +} diff --git a/jellyflood tvOS/Views/FontPickerView.swift b/jellyflood tvOS/Views/FontPickerView.swift new file mode 100644 index 00000000..2e9d4889 --- /dev/null +++ b/jellyflood tvOS/Views/FontPickerView.swift @@ -0,0 +1,53 @@ +// +// 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 + +struct FontPickerView: View { + + @Binding + private var selection: String + + @State + private var updateSelection: String + + init(selection: Binding) { + self._selection = selection + self.updateSelection = selection.wrappedValue + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "character.textbox") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + ForEach(UIFont.familyNames, id: \.self) { fontFamily in + Button { + selection = fontFamily + updateSelection = fontFamily + } label: { + HStack { + Text(fontFamily) + .font(.custom(fontFamily, size: 28)) + + Spacer() + + if updateSelection == fontFamily { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } +} diff --git a/jellypig tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/jellyflood tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift rename to jellyflood tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift diff --git a/jellypig tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/jellyflood tvOS/Views/HomeView/Components/CinematicResumeItemView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/Components/CinematicResumeItemView.swift rename to jellyflood tvOS/Views/HomeView/Components/CinematicResumeItemView.swift diff --git a/jellypig tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/jellyflood tvOS/Views/HomeView/Components/LatestInLibraryView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/Components/LatestInLibraryView.swift rename to jellyflood tvOS/Views/HomeView/Components/LatestInLibraryView.swift diff --git a/jellypig tvOS/Views/HomeView/Components/NextUpView.swift b/jellyflood tvOS/Views/HomeView/Components/NextUpView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/Components/NextUpView.swift rename to jellyflood tvOS/Views/HomeView/Components/NextUpView.swift diff --git a/jellypig tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/jellyflood tvOS/Views/HomeView/Components/RecentlyAddedView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/Components/RecentlyAddedView.swift rename to jellyflood tvOS/Views/HomeView/Components/RecentlyAddedView.swift diff --git a/jellypig tvOS/Views/HomeView/HomeView.swift b/jellyflood tvOS/Views/HomeView/HomeView.swift similarity index 100% rename from jellypig tvOS/Views/HomeView/HomeView.swift rename to jellyflood tvOS/Views/HomeView/HomeView.swift diff --git a/jellyflood tvOS/Views/ItemOverviewView.swift b/jellyflood tvOS/Views/ItemOverviewView.swift new file mode 100644 index 00000000..8cd80faf --- /dev/null +++ b/jellyflood tvOS/Views/ItemOverviewView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ItemOverviewView: View { + + let item: BaseItemDto + + @ViewBuilder + private var content: some View { + GeometryReader { proxy in + VStack(alignment: .center) { + + Text(item.displayTitle) + .font(.title) + .frame(maxHeight: proxy.size.height * 0.33) + + VStack(alignment: .leading, spacing: 20) { + if let tagline = item.taglines?.first { + Text(tagline) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + } + + if let overview = item.overview { + Text(overview) + } + } + } + .padding(.horizontal, 100) + } + } + + var body: some View { + ZStack { + BlurView() + + content + } + .ignoresSafeArea() + } +} diff --git a/jellypig tvOS/Views/ItemView/CinematicCollectionItemView.swift b/jellyflood tvOS/Views/ItemView/CinematicCollectionItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CinematicCollectionItemView.swift rename to jellyflood tvOS/Views/ItemView/CinematicCollectionItemView.swift diff --git a/jellypig tvOS/Views/ItemView/CinematicEpisodeItemView.swift b/jellyflood tvOS/Views/ItemView/CinematicEpisodeItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CinematicEpisodeItemView.swift rename to jellyflood tvOS/Views/ItemView/CinematicEpisodeItemView.swift diff --git a/jellypig tvOS/Views/ItemView/CinematicItemAboutView.swift b/jellyflood tvOS/Views/ItemView/CinematicItemAboutView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CinematicItemAboutView.swift rename to jellyflood tvOS/Views/ItemView/CinematicItemAboutView.swift diff --git a/jellypig tvOS/Views/ItemView/CinematicItemViewTopRow.swift b/jellyflood tvOS/Views/ItemView/CinematicItemViewTopRow.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CinematicItemViewTopRow.swift rename to jellyflood tvOS/Views/ItemView/CinematicItemViewTopRow.swift diff --git a/jellypig tvOS/Views/ItemView/CinematicSeasonItemView.swift b/jellyflood tvOS/Views/ItemView/CinematicSeasonItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CinematicSeasonItemView.swift rename to jellyflood tvOS/Views/ItemView/CinematicSeasonItemView.swift diff --git a/jellypig tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/jellyflood tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift rename to jellyflood tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift diff --git a/jellypig tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift b/jellyflood tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift rename to jellyflood tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift diff --git a/jellyflood tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/AboutView.swift new file mode 100644 index 00000000..b87140ef --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/AboutView/AboutView.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct AboutView: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + L10n.about.text + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 30) { + ImageCard(viewModel: viewModel) + + OverviewCard(item: viewModel.item) + + if let mediaSources = viewModel.item.mediaSources { + ForEach(mediaSources) { source in + MediaSourcesCard(subtitle: mediaSources.count > 1 ? source.displayTitle : nil, source: source) + } + } + + if viewModel.item.hasRatings { + RatingsCard(item: viewModel.item) + } + } + .padding(50) + } + } + .focusSection() + } + } +} diff --git a/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift new file mode 100644 index 00000000..ed2b2561 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift @@ -0,0 +1,67 @@ +// +// 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.AboutView { + + struct Card: View { + + private var content: () -> any View + private var onSelect: () -> Void + private let title: String + private let subtitle: String? + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + Text(title) + .font(.title3) + .fontWeight(.semibold) + .lineLimit(2) + + if let subtitle { + Text(subtitle) + .font(.subheadline) + } + + Spacer() + .frame(maxWidth: .infinity) + + content() + .eraseToAnyView() + } + .padding() + .frame(width: 700, height: 405) + } + .buttonStyle(.card) + } + } +} + +extension ItemView.AboutView.Card { + + init(title: String, subtitle: String? = nil) { + self.init( + content: { EmptyView() }, + onSelect: {}, + title: title, + subtitle: subtitle + ) + } + + func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.content, with: content) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift rename to jellyflood tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift rename to jellyflood tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift diff --git a/jellypig tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift rename to jellyflood tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift diff --git a/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift new file mode 100644 index 00000000..def7d361 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift @@ -0,0 +1,55 @@ +// +// 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.AboutView { + + struct RatingsCard: View { + + let item: BaseItemDto + + var body: some View { + Card(title: L10n.ratings) + .content { + HStack(alignment: .bottom) { + if let criticRating = item.criticRating { + VStack { + Group { + if criticRating >= 60 { + Image(.tomatoFresh) + } else { + Image(.tomatoRotten) + } + } + .symbolRenderingMode(.multicolor) + .foregroundStyle(.green, .red) + .font(.largeTitle) + + Text("\(criticRating, specifier: "%.0f")") + .font(.title3) + } + } + + if let communityRating = item.communityRating { + VStack { + Image(systemName: "star.fill") + .symbolRenderingMode(.multicolor) + .foregroundStyle(.yellow) + .font(.largeTitle) + + Text("\(communityRating, specifier: "%.1f")") + .font(.title3) + } + } + } + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/ActionButton.swift b/jellyflood tvOS/Views/ItemView/Components/ActionButton.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/ActionButton.swift rename to jellyflood tvOS/Views/ItemView/Components/ActionButton.swift diff --git a/jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift diff --git a/jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift b/jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift rename to jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/Components/RefreshMetadataButton.swift diff --git a/jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift rename to jellyflood tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift diff --git a/jellypig tvOS/Views/ItemView/Components/AttributeHStack.swift b/jellyflood tvOS/Views/ItemView/Components/AttributeHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/AttributeHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/AttributeHStack.swift diff --git a/jellypig tvOS/Views/ItemView/Components/CastAndCrewHStack.swift b/jellyflood tvOS/Views/ItemView/Components/CastAndCrewHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/CastAndCrewHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/CastAndCrewHStack.swift diff --git a/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift new file mode 100644 index 00000000..2d566331 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EmptyCard: View { + + private var onSelect: () -> Void + + init() { + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + VStack(alignment: .leading) { + Button { + onSelect() + } label: { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "questionmark") + .font(.system(size: 40)) + } + } + .buttonStyle(.card) + .posterShadow() + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.noResults, + content: L10n.noEpisodesAvailable + ) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift rename to jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift rename to jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift diff --git a/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift new file mode 100644 index 00000000..8ec8437d --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift @@ -0,0 +1,51 @@ +// +// 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 SeriesEpisodeSelector { + + struct ErrorCard: View { + + let error: JellyfinAPIError + private var onSelect: () -> Void + + init(error: JellyfinAPIError) { + self.error = error + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + VStack(alignment: .leading) { + Button { + onSelect() + } label: { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "arrow.clockwise") + .font(.system(size: 40)) + } + } + .buttonStyle(.card) + .posterShadow() + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.error, + content: error.localizedDescription + ) + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift diff --git a/jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift diff --git a/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift new file mode 100644 index 00000000..f28bb097 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension SeriesEpisodeSelector { + + struct LoadingCard: View { + + private var onSelect: () -> Void + + init() { + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + VStack(alignment: .leading) { + Button { + onSelect() + } label: { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + ProgressView() + } + } + .buttonStyle(.card) + .posterShadow() + + SeriesEpisodeSelector.EpisodeContent( + subHeader: String.random(count: 7 ..< 12), + header: String.random(count: 10 ..< 20), + content: String.random(count: 20 ..< 80) + ) + .redacted(reason: .placeholder) + } + } + } +} diff --git a/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift new file mode 100644 index 00000000..cb527d34 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -0,0 +1,67 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import SwiftUI + +struct SeriesEpisodeSelector: View { + + // MARK: - Observed & Environment Objects + + @ObservedObject + var viewModel: SeriesItemViewModel + + @EnvironmentObject + private var parentFocusGuide: FocusGuide + + // MARK: - State Variables + + @State + private var didSelectPlayButtonSeason = false + @State + private var selection: SeasonItemViewModel.ID? + + // MARK: - Calculated Variables + + private var selectionViewModel: SeasonItemViewModel? { + viewModel.seasons.first(where: { $0.id == selection }) + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + SeasonsHStack(viewModel: viewModel, selection: $selection) + .environmentObject(parentFocusGuide) + + if let selectionViewModel { + EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) + .environmentObject(parentFocusGuide) + } + } + .onReceive(viewModel.playButtonItem.publisher) { newValue in + + guard !didSelectPlayButtonSeason else { return } + didSelectPlayButtonSeason = true + + if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) { + selection = playButtonSeason.id + } else { + selection = viewModel.seasons.first?.id + } + } + .onChange(of: selection) { _, _ in + guard let selectionViewModel else { return } + + if selectionViewModel.state == .initial { + selectionViewModel.send(.refresh) + } + } + } +} diff --git a/jellyflood tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift b/jellyflood tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift new file mode 100644 index 00000000..f7e956b1 --- /dev/null +++ b/jellyflood tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift @@ -0,0 +1,52 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct VersionMenu: View { + + // MARK: - Focus State + + @FocusState + private var isFocused: Bool + + @ObservedObject + var viewModel: ItemViewModel + + let mediaSources: [MediaSourceInfo] + + // MARK: - Selected Media Source Binding + + private var selectedMediaSource: Binding { + Binding( + get: { viewModel.selectedMediaSource }, + set: { newSource in + if let newSource { + viewModel.send(.selectMediaSource(newSource)) + } + } + ) + } + + // MARK: - Body + + var body: some View { + ActionButton(L10n.version, icon: "list.dash") { + Picker(L10n.version, selection: selectedMediaSource) { + ForEach(mediaSources, id: \.hashValue) { mediaSource in + Text(mediaSource.displayTitle) + .tag(mediaSource as MediaSourceInfo?) + } + } + } + } + } +} diff --git a/jellypig tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift b/jellyflood tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift rename to jellyflood tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift diff --git a/jellypig tvOS/Views/ItemView/Components/SimilarItemsHStack.swift b/jellyflood tvOS/Views/ItemView/Components/SimilarItemsHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/SimilarItemsHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/SimilarItemsHStack.swift diff --git a/jellypig tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift b/jellyflood tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift rename to jellyflood tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift diff --git a/jellypig tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift b/jellyflood tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift rename to jellyflood tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift diff --git a/jellypig tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift b/jellyflood tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift rename to jellyflood tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift diff --git a/jellypig tvOS/Views/ItemView/ItemView.swift b/jellyflood tvOS/Views/ItemView/ItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/ItemView.swift rename to jellyflood tvOS/Views/ItemView/ItemView.swift diff --git a/jellypig tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift b/jellyflood tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift rename to jellyflood tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift diff --git a/jellypig tvOS/Views/ItemView/MovieItemView/MovieItemView.swift b/jellyflood tvOS/Views/ItemView/MovieItemView/MovieItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/MovieItemView/MovieItemView.swift rename to jellyflood tvOS/Views/ItemView/MovieItemView/MovieItemView.swift diff --git a/jellypig tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/jellyflood tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift rename to jellyflood tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift diff --git a/jellypig tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift b/jellyflood tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift rename to jellyflood tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift diff --git a/jellypig tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift b/jellyflood tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift similarity index 100% rename from jellypig tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift rename to jellyflood tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift diff --git a/jellypig tvOS/Views/LearnMoreModal.swift b/jellyflood tvOS/Views/LearnMoreModal.swift similarity index 100% rename from jellypig tvOS/Views/LearnMoreModal.swift rename to jellyflood tvOS/Views/LearnMoreModal.swift diff --git a/jellypig tvOS/Views/MediaSourceInfoView.swift b/jellyflood tvOS/Views/MediaSourceInfoView.swift similarity index 100% rename from jellypig tvOS/Views/MediaSourceInfoView.swift rename to jellyflood tvOS/Views/MediaSourceInfoView.swift diff --git a/jellyflood tvOS/Views/MediaView/Components/MediaItem.swift b/jellyflood tvOS/Views/MediaView/Components/MediaItem.swift new file mode 100644 index 00000000..a8457adf --- /dev/null +++ b/jellyflood tvOS/Views/MediaView/Components/MediaItem.swift @@ -0,0 +1,122 @@ +// +// 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 MediaView { + + // TODO: custom view for folders and tv (allow customization?) + struct MediaItem: View { + + @Default(.Customization.Library.randomImage) + private var useRandomImage + + @ObservedObject + var viewModel: MediaViewModel + + @State + private var imageSources: [ImageSource] = [] + + private var onSelect: () -> Void + private let mediaType: MediaViewModel.MediaType + + init(viewModel: MediaViewModel, type: MediaViewModel.MediaType) { + self.viewModel = viewModel + self.onSelect = {} + self.mediaType = type + } + + private var useTitleLabel: Bool { + useRandomImage || + mediaType == .downloads || + mediaType == .favorites + } + + private func setImageSources() { + Task { @MainActor in + if useRandomImage { + self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) + return + } + + if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } + } + } + + @ViewBuilder + private var titleLabel: some View { + Text(mediaType.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + + private func titleLabelOverlay(with content: Content) -> some View { + ZStack { + content + + Color.black + .opacity(0.5) + + titleLabel + .foregroundStyle(.white) + } + } + + var body: some View { + Button { + onSelect() + } label: { + ZStack { + Color.clear + + ImageView(imageSources) + .image { image in + if useTitleLabel { + titleLabelOverlay(with: image) + } else { + image + } + } + .placeholder { imageSource in + titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) + } + .failure { + Color.secondarySystemFill + .opacity(0.75) + .overlay { + titleLabel + .foregroundColor(.primary) + } + } + .id(imageSources.hashValue) + } + .posterStyle(.landscape) + } + .buttonStyle(.card) + .onFirstAppear(perform: setImageSources) + .onChange(of: useRandomImage) { _, _ in + setImageSources() + } + } + } +} + +extension MediaView.MediaItem { + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/jellypig tvOS/Views/MediaView/MediaView.swift b/jellyflood tvOS/Views/MediaView/MediaView.swift similarity index 100% rename from jellypig tvOS/Views/MediaView/MediaView.swift rename to jellyflood tvOS/Views/MediaView/MediaView.swift diff --git a/jellypig tvOS/Views/PagingLibraryView/Components/LibraryRow.swift b/jellyflood tvOS/Views/PagingLibraryView/Components/LibraryRow.swift similarity index 100% rename from jellypig tvOS/Views/PagingLibraryView/Components/LibraryRow.swift rename to jellyflood tvOS/Views/PagingLibraryView/Components/LibraryRow.swift diff --git a/jellypig tvOS/Views/PagingLibraryView/Components/ListRow.swift b/jellyflood tvOS/Views/PagingLibraryView/Components/ListRow.swift similarity index 100% rename from jellypig tvOS/Views/PagingLibraryView/Components/ListRow.swift rename to jellyflood tvOS/Views/PagingLibraryView/Components/ListRow.swift diff --git a/jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/jellyflood tvOS/Views/PagingLibraryView/PagingLibraryView.swift similarity index 100% rename from jellypig tvOS/Views/PagingLibraryView/PagingLibraryView.swift rename to jellyflood tvOS/Views/PagingLibraryView/PagingLibraryView.swift diff --git a/jellypig tvOS/Views/ProgramGuideView.swift b/jellyflood tvOS/Views/ProgramGuideView.swift similarity index 100% rename from jellypig tvOS/Views/ProgramGuideView.swift rename to jellyflood tvOS/Views/ProgramGuideView.swift diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift b/jellyflood tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift similarity index 100% rename from jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift rename to jellyflood tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift b/jellyflood tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift similarity index 100% rename from jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift rename to jellyflood tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift b/jellyflood tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift similarity index 100% rename from jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift rename to jellyflood tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift diff --git a/jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift b/jellyflood tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift similarity index 100% rename from jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift rename to jellyflood tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift diff --git a/jellypig tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift b/jellyflood tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift similarity index 100% rename from jellypig tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift rename to jellyflood tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift diff --git a/jellyflood tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/jellyflood tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift new file mode 100644 index 00000000..7c5ad24a --- /dev/null +++ b/jellyflood tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -0,0 +1,37 @@ +// +// 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 ProgramsView { + + struct ProgramProgressOverlay: View { + + @State + private var programProgress: Double = 0.0 + + let program: BaseItemDto + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + WrappedView { + if let startDate = program.startDate, startDate < Date.now { + LandscapePosterProgressBar( + progress: program.programProgress ?? 0 + ) + } + } + .onReceive(timer) { newValue in + if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { + programProgress = newValue.timeIntervalSince(startDate) / duration + } + } + } + } +} diff --git a/jellypig tvOS/Views/ProgramsView/ProgramsView.swift b/jellyflood tvOS/Views/ProgramsView/ProgramsView.swift similarity index 100% rename from jellypig tvOS/Views/ProgramsView/ProgramsView.swift rename to jellyflood tvOS/Views/ProgramsView/ProgramsView.swift diff --git a/jellypig tvOS/Views/QuickConnectView.swift b/jellyflood tvOS/Views/QuickConnectView.swift similarity index 100% rename from jellypig tvOS/Views/QuickConnectView.swift rename to jellyflood tvOS/Views/QuickConnectView.swift diff --git a/jellypig tvOS/Views/SearchView.swift b/jellyflood tvOS/Views/SearchView.swift similarity index 100% rename from jellypig tvOS/Views/SearchView.swift rename to jellyflood tvOS/Views/SearchView.swift diff --git a/jellyflood tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift b/jellyflood tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift new file mode 100644 index 00000000..f43aac44 --- /dev/null +++ b/jellyflood tvOS/Views/SelectUserView/Components/AddUserBottomButton.swift @@ -0,0 +1,67 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserBottomButton: View { + + // MARK: Properties + + private let action: (ServerState) -> Void + private let selectedServer: ServerState? + private let servers: OrderedSet + + // MARK: View Builders + + @ViewBuilder + private var label: some View { + Label(L10n.addUser, systemImage: "plus") + .foregroundStyle(Color.primary) + .font(.body.weight(.semibold)) + .labelStyle(.iconOnly) + .frame(width: 50, height: 50) + } + + // MARK: - Initializer + + init( + selectedServer: ServerState?, + servers: OrderedSet, + action: @escaping (ServerState) -> Void + ) { + self.action = action + self.selectedServer = selectedServer + self.servers = servers + } + + // MARK: Body + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + } + } +} diff --git a/jellyflood tvOS/Views/SelectUserView/Components/AddUserGridButton.swift b/jellyflood tvOS/Views/SelectUserView/Components/AddUserGridButton.swift new file mode 100644 index 00000000..ca99cf75 --- /dev/null +++ b/jellyflood tvOS/Views/SelectUserView/Components/AddUserGridButton.swift @@ -0,0 +1,70 @@ +// +// 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 OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserGridButton: View { + + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var label: some View { + ZStack { + Color.secondarySystemFill + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + .hoverEffect(.highlight) + + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if selectedServer == nil { + // For layout, not to be localized + Text("Hidden") + .font(.footnote) + .hidden() + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + .buttonStyle(.borderless) + .buttonBorderShape(.circle) + } + } +} diff --git a/jellypig tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift b/jellyflood tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift similarity index 100% rename from jellypig tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift rename to jellyflood tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift diff --git a/jellypig tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/jellyflood tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift similarity index 97% rename from jellypig tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift rename to jellyflood tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift index 2821d8bf..35d63c29 100644 --- a/jellypig tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift +++ b/jellyflood tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -86,7 +86,7 @@ extension SelectUserView { } Button(L10n.addServer, systemImage: "plus") { - router.route(to: \.connectToServer) + router.route(to: \.dualServerConnect) } } } label: { diff --git a/jellyflood tvOS/Views/SelectUserView/Components/UserGridButton.swift b/jellyflood tvOS/Views/SelectUserView/Components/UserGridButton.swift new file mode 100644 index 00000000..0dad4647 --- /dev/null +++ b/jellyflood tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -0,0 +1,104 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension SelectUserView { + + struct UserGridButton: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + private let user: UserState + private let server: ServerState + private let showServer: Bool + private let action: () -> Void + private let onDelete: () -> Void + + // MARK: - Initializer + + init( + user: UserState, + server: ServerState, + showServer: Bool, + action: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.user = user + self.server = server + self.showServer = showServer + self.action = action + self.onDelete = onDelete + } + + // MARK: - Label Foreground Style + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ), + pipeline: .Swiftfin.local + ) + .overlay(alignment: .bottom) { + if isEditing && isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 75, height: 75) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + } + } + .hoverEffect(.highlight) + + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(1) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.borderless) + .buttonBorderShape(.circle) + .contextMenu { + if !isEditing { + Button( + L10n.delete, + role: .destructive, + action: onDelete + ) + } + } + } + } +} diff --git a/jellypig tvOS/Views/SelectUserView/SelectUserView.swift b/jellyflood tvOS/Views/SelectUserView/SelectUserView.swift similarity index 100% rename from jellypig tvOS/Views/SelectUserView/SelectUserView.swift rename to jellyflood tvOS/Views/SelectUserView/SelectUserView.swift diff --git a/jellypig tvOS/Views/ServerDetailView.swift b/jellyflood tvOS/Views/ServerDetailView.swift similarity index 100% rename from jellypig tvOS/Views/ServerDetailView.swift rename to jellyflood tvOS/Views/ServerDetailView.swift diff --git a/jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift new file mode 100644 index 00000000..53bdc87c --- /dev/null +++ b/jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift @@ -0,0 +1,65 @@ +// +// 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 Foundation +import SwiftUI + +extension CustomDeviceProfileSettingsView { + struct CustomProfileButton: View { + let profile: CustomDeviceProfile + var onSelect: () -> Void + + @ViewBuilder + private func profileDetailsView(title: String, detail: String) -> some View { + VStack(alignment: .leading) { + Text(title) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + Text(detail) + .foregroundColor(.secondary) + } + .font(.subheadline) + } + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 8) { + profileDetailsView( + title: L10n.audio, + detail: profile.audio.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.video, + detail: profile.video.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.containers, + detail: profile.container.map(\.displayTitle).joined(separator: ", ") + ) + + profileDetailsView( + title: L10n.useAsTranscodingProfile, + detail: profile.useAsTranscodingProfile ? L10n.yes : L10n.no + ) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + .padding() + } + } + } +} diff --git a/jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift rename to jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift diff --git a/jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift rename to jellyflood tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift diff --git a/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift new file mode 100644 index 00000000..0b0277bf --- /dev/null +++ b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/ListColumnsPickerView.swift @@ -0,0 +1,25 @@ +// +// 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 + +struct ListColumnsPickerView: View { + + @Binding + var selection: Int + + var body: some View { + StepperView( + title: L10n.columns, + value: $selection, + range: 1 ... 3, + step: 1 + ) + } +} diff --git a/jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift rename to jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift diff --git a/jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift rename to jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift diff --git a/jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift rename to jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift diff --git a/jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift rename to jellyflood tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift diff --git a/jellyflood tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/jellyflood tvOS/Views/SettingsView/ExperimentalSettingsView.swift new file mode 100644 index 00000000..6af5a3e6 --- /dev/null +++ b/jellyflood tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -0,0 +1,28 @@ +// +// 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 + +// Note: Used for experimental settings that may be removed or implemented +// officially. Keep for future settings. + +struct ExperimentalSettingsView: View { + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView {} + .navigationTitle(L10n.experimental) + } +} diff --git a/jellyflood tvOS/Views/SettingsView/IndicatorSettingsView.swift b/jellyflood tvOS/Views/SettingsView/IndicatorSettingsView.swift new file mode 100644 index 00000000..cd456456 --- /dev/null +++ b/jellyflood tvOS/Views/SettingsView/IndicatorSettingsView.swift @@ -0,0 +1,48 @@ +// +// 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 + +// TODO: show a sample poster to model indicators + +struct IndicatorSettingsView: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnwatched + @Default(.Customization.Indicators.showPlayed) + private var showWatched + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section(L10n.posters) { + + Toggle(L10n.showFavorited, isOn: $showFavorited) + + Toggle(L10n.showProgress, isOn: $showProgress) + + Toggle(L10n.showUnwatched, isOn: $showUnwatched) + + Toggle(L10n.showWatched, isOn: $showWatched) + } + } + .navigationTitle(L10n.indicators) + } +} diff --git a/jellypig tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift b/jellyflood tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift rename to jellyflood tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift diff --git a/jellypig tvOS/Views/SettingsView/SettingsView.swift b/jellyflood tvOS/Views/SettingsView/SettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/SettingsView.swift rename to jellyflood tvOS/Views/SettingsView/SettingsView.swift diff --git a/jellypig tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift b/jellyflood tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift rename to jellyflood tvOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift diff --git a/jellypig tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/jellyflood tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift rename to jellyflood tvOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift diff --git a/jellypig tvOS/Views/SettingsView/VideoPlayerSettingsView.swift b/jellyflood tvOS/Views/SettingsView/VideoPlayerSettingsView.swift similarity index 100% rename from jellypig tvOS/Views/SettingsView/VideoPlayerSettingsView.swift rename to jellyflood tvOS/Views/SettingsView/VideoPlayerSettingsView.swift diff --git a/jellypig tvOS/Views/UserSignInView/Components/PublicUserButton.swift b/jellyflood tvOS/Views/UserSignInView/Components/PublicUserButton.swift similarity index 100% rename from jellypig tvOS/Views/UserSignInView/Components/PublicUserButton.swift rename to jellyflood tvOS/Views/UserSignInView/Components/PublicUserButton.swift diff --git a/jellypig tvOS/Views/UserSignInView/UserSignInView.swift b/jellyflood tvOS/Views/UserSignInView/UserSignInView.swift similarity index 100% rename from jellypig tvOS/Views/UserSignInView/UserSignInView.swift rename to jellyflood tvOS/Views/UserSignInView/UserSignInView.swift diff --git a/jellypig tvOS/Views/VideoPlayer/Components/LoadingView.swift b/jellyflood tvOS/Views/VideoPlayer/Components/LoadingView.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Components/LoadingView.swift rename to jellyflood tvOS/Views/VideoPlayer/Components/LoadingView.swift diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/jellyflood tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift new file mode 100644 index 00000000..c6ff9e62 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -0,0 +1,228 @@ +// +// 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 AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct LiveNativeVideoPlayer: View { + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + + init(manager: LiveVideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + LiveNativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + ZStack { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + } + .navigationBarHidden(true) + .ignoresSafeArea() + } +} + +struct LiveNativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: VideoPlayerManager + + func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController { + UILiveNativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {} +} + +class UILiveNativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: VideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: VideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + print("🎬 [LiveNativeVideoPlayer] Creating player with URL: \(manager.currentViewModel.playbackURL)") + print("🎬 [LiveNativeVideoPlayer] URL absoluteString: \(manager.currentViewModel.playbackURL.absoluteString)") + + // Create AVURLAsset with options for live streaming + let asset = AVURLAsset(url: manager.currentViewModel.playbackURL, options: [ + AVURLAssetPreferPreciseDurationAndTimingKey: false, + ]) + + // Create player item from asset + let playerItem = AVPlayerItem(asset: asset) + + // Configure for live streaming + playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = true + playerItem.preferredForwardBufferDuration = 1.0 // Minimal buffer for live streams + + let newPlayer = AVPlayer(playerItem: playerItem) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.automaticallyWaitsToMinimizeStalling = false // Don't wait for buffer, start immediately + playerItem.externalMetadata = createMetadata() + + // Observe player item status to detect errors + if let playerItem = newPlayer.currentItem { + playerItem.addObserver(self, forKeyPath: "status", options: [.new, .old], context: nil) + print("🎬 [LiveNativeVideoPlayer] Added status observer to player item") + } + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + if keyPath == "status" { + if let playerItem = object as? AVPlayerItem { + print("🎬 [LiveNativeVideoPlayer] Player item status changed to: \(playerItem.status.rawValue)") + switch playerItem.status { + case .failed: + if let error = playerItem.error { + print("🎬 [LiveNativeVideoPlayer] ERROR: Player item failed with error: \(error)") + print("🎬 [LiveNativeVideoPlayer] Error domain: \(error._domain), code: \(error._code)") + print("🎬 [LiveNativeVideoPlayer] Error description: \(error.localizedDescription)") + } + case .readyToPlay: + print("🎬 [LiveNativeVideoPlayer] Player item is ready to play") + case .unknown: + print("🎬 [LiveNativeVideoPlayer] Player item status is unknown") + @unknown default: + print("🎬 [LiveNativeVideoPlayer] Player item status is unknown default") + } + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Remove status observer + player?.currentItem?.removeObserver(self, forKeyPath: "status") + + stop() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift new file mode 100644 index 00000000..ccb836d7 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift @@ -0,0 +1,114 @@ +// +// 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 LiveVideoPlayer.Overlay { + + struct LiveBottomBarView: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: LiveVideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @FocusState + private var isBarFocused: Bool + + @ViewBuilder + private var playbackStateView: some View { +// if videoPlayerManager.state == .playing { +// Image(systemName: "pause.circle") +// } else if videoPlayerManager.state == .paused { +// Image(systemName: "play.circle") +// } else { +// ProgressView() +// } + // videoPLayerManager access is giving an error here: + // Fatal error: No ObservableObject of type LiveVideoPlayerManager found. A View.environmentObject(_:) for + // LiveVideoPlayerManager may be missing as an ancestor of this view. + EmptyView() + } + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { + +// if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) { +// Text(subtitle.title) +// .font(.subheadline) +// .foregroundColor(.white) +// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in +// dimensions[.leading] +// } +// } + + HStack { + + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.bold) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + VideoPlayer.Overlay.BarActionButtons() + } + + tvOSSliderView(value: $currentProgressHandler.scrubbedProgress) + .onEditingChanged { isEditing in + isScrubbing = isEditing + + if isEditing { + overlayTimer.pause() + } else { + overlayTimer.start(5) + } + } + .focused($isBarFocused) + .frame(height: 60) + // .visible(isScrubbing || isPresentingOverlay) + + HStack(spacing: 15) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .monospacedDigit() + .foregroundColor(.white) + + playbackStateView + .frame(maxWidth: 40, maxHeight: 40) + + Spacer() + + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .monospacedDigit() + .foregroundColor(.white) + } + } + .onChange(of: isPresentingOverlay) { _, newValue in + guard newValue else { return } + } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift new file mode 100644 index 00000000..20fe278a --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift @@ -0,0 +1,92 @@ +// +// 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 +import VLCUI + +extension LiveVideoPlayer { + +// struct LoadingOverlay: View { +// +// @Environment(\.isPresentingOverlay) +// @Binding +// private var isPresentingOverlay +// +// @EnvironmentObject +// private var proxy: VLCVideoPlayer.Proxy +// @EnvironmentObject +// private var router: LiveVideoPlayerCoordinator.Router +// +// @State +// private var confirmCloseWorkItem: DispatchWorkItem? +// @State +// private var currentOverlayType: VideoPlayer.OverlayType = .main +// +// @StateObject +// private var overlayTimer: TimerProxy = .init() +// +// var body: some View { +// ZStack { +// +// ConfirmCloseOverlay() +// .visible(currentOverlayType == .confirmClose) +// } +// .visible(isPresentingOverlay) +// .animation(.linear(duration: 0.1), value: currentOverlayType) +// .environment(\.currentOverlayType, $currentOverlayType) +// .environmentObject(overlayTimer) +// .onChange(of: currentOverlayType) { _, newValue in +// if [.smallMenu, .chapters].contains(newValue) { +// overlayTimer.pause() +// } else if isPresentingOverlay { +// overlayTimer.start(5) +// } +// } +// .onChange(of: overlayTimer.isActive) { _, isActive in +// guard !isActive else { return } +// +// withAnimation(.linear(duration: 0.3)) { +// isPresentingOverlay = false +// } +// } +// .onSelectPressed { +// currentOverlayType = .main +// isPresentingOverlay = true +// overlayTimer.start(5) +// } +// .onMenuPressed { +// +// overlayTimer.start(5) +// confirmCloseWorkItem?.cancel() +// +// if isPresentingOverlay && currentOverlayType == .confirmClose { +// proxy.stop() +// router.dismissCoordinator() +// } else if isPresentingOverlay && currentOverlayType == .smallMenu { +// currentOverlayType = .main +// } else { +// withAnimation { +// currentOverlayType = .confirmClose +// isPresentingOverlay = true +// } +// +// let task = DispatchWorkItem { +// withAnimation { +// isPresentingOverlay = false +// overlayTimer.stop() +// } +// } +// +// confirmCloseWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// } +// } +// } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift new file mode 100644 index 00000000..a1e91903 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift @@ -0,0 +1,53 @@ +// +// 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 LiveVideoPlayer { + + struct LiveMainOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + VStack { + + Spacer() + + Overlay.LiveBottomBarView() + .padding() + .padding() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .environmentObject(overlayTimer) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift new file mode 100644 index 00000000..79eca923 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift @@ -0,0 +1,101 @@ +// +// 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 +import VLCUI + +extension LiveVideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @EnvironmentObject + private var proxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @State + private var confirmCloseWorkItem: DispatchWorkItem? + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + @StateObject + private var overlayTimer: TimerProxy = .init() + + var body: some View { + ZStack { + + LiveMainOverlay() + .visible(currentOverlayType == .main) + + ConfirmCloseOverlay() + .visible(currentOverlayType == .confirmClose) + + VideoPlayer.SmallMenuOverlay() + .visible(currentOverlayType == .smallMenu) + + VideoPlayer.ChapterOverlay() + .visible(currentOverlayType == .chapters) + } + .visible(isPresentingOverlay) + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .environmentObject(overlayTimer) + .onChange(of: currentOverlayType) { _, newValue in + if [.smallMenu, .chapters].contains(newValue) { + overlayTimer.pause() + } else if isPresentingOverlay { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { _, isActive in + guard !isActive else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } +// .onSelectPressed { +// currentOverlayType = .main +// isPresentingOverlay = true +// overlayTimer.start(5) +// } +// .onMenuPressed { +// +// overlayTimer.start(5) +// confirmCloseWorkItem?.cancel() +// +// if isPresentingOverlay && currentOverlayType == .confirmClose { +// proxy.stop() +// router.dismissCoordinator() +// } else if isPresentingOverlay && currentOverlayType == .smallMenu { +// currentOverlayType = .main +// } else { +// withAnimation { +// currentOverlayType = .confirmClose +// isPresentingOverlay = true +// } +// +// let task = DispatchWorkItem { +// withAnimation { +// isPresentingOverlay = false +// overlayTimer.stop() +// } +// } +// +// confirmCloseWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/LiveVideoPlayer.swift b/jellyflood tvOS/Views/VideoPlayer/LiveVideoPlayer.swift new file mode 100644 index 00000000..f3ef9b2d --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/LiveVideoPlayer.swift @@ -0,0 +1,117 @@ +// +// 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 +import VLCUI + +struct LiveVideoPlayer: View { + + enum OverlayType { + case chapters + case confirmClose + case main + case smallMenu + } + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + + @ViewBuilder + private var playerView: some View { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator() + } + } + } + + LiveVideoPlayer.Overlay() + .eraseToAnyView() + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { _, newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + } + + @ViewBuilder + private var loadingView: some View { + VideoPlayer.LoadingView() + } + + var body: some View { + ZStack { + + Color.black + + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + loadingView + } + } + .ignoresSafeArea() + .onChange(of: isScrubbing) { _, newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + } +} + +extension LiveVideoPlayer { + + init(manager: LiveVideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager + ) + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/NativeVideoPlayer.swift b/jellyflood tvOS/Views/VideoPlayer/NativeVideoPlayer.swift new file mode 100644 index 00000000..f54e94fa --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/NativeVideoPlayer.swift @@ -0,0 +1,176 @@ +// +// 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 AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct NativeVideoPlayer: View { + + @Environment(\.scenePhase) + var scenePhase + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + init(manager: VideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + NativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + .navigationBarHidden(true) + .ignoresSafeArea() + } +} + +struct NativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: VideoPlayerManager + + func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController { + UINativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {} +} + +// TODO: Refactor such that this does not subclass AVPlayerViewController. Subclassing is not +// supported according to the apple docs. +class UINativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: VideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: VideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + let newPlayer: AVPlayer = .init(url: manager.currentViewModel.playbackURL) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.currentItem?.externalMetadata = createMetadata() + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift new file mode 100644 index 00000000..e77fd32a --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -0,0 +1,109 @@ +// +// 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 +import VLCUI + +extension VideoPlayer { + + struct ChapterOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var scrollViewProxy: ScrollViewProxy? = nil + + var body: some View { + VStack { + + Spacer() + + HStack { + + L10n.chapters.text + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + } + .padding() + .padding() + + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(viewModel.chapters, id: \.hashValue) { chapter in + PosterButton( + item: chapter, + type: .landscape + ) + .imageOverlay { + if chapter.secondsRange.contains(currentProgressHandler.seconds) { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 8) + } + } + .content { + VStack(alignment: .leading, spacing: 5) { + Text(chapter.chapterInfo.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(.white) + + Text(chapter.chapterInfo.timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .onSelect { + let seconds = chapter.chapterInfo.startTimeSeconds + videoPlayerProxy.setTime(.seconds(seconds)) + + if videoPlayerManager.state != .playing { + videoPlayerProxy.play() + } + } + } + } + .padding() + .padding(.horizontal) + } + .onChange(of: currentOverlayType) { _, newValue in + guard newValue == .chapters else { return } + if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { + scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) + } + } + .onAppear { + scrollViewProxy = proxy + } + } + } + } + } +} diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift rename to jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift new file mode 100644 index 00000000..b182b25b --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift @@ -0,0 +1,33 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct AutoPlay: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + SFSymbolButton( + systemName: autoPlayEnabled ? "play.circle.fill" : "stop.circle" + ) + .onSelect { + autoPlayEnabled.toggle() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift new file mode 100644 index 00000000..4c7099b3 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift @@ -0,0 +1,33 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct Chapters: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + SFSymbolButton( + systemName: "photo", + systemNameFocused: "photo.fill" + ) + .onSelect { + currentOverlayType = .chapters + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift new file mode 100644 index 00000000..92594014 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift @@ -0,0 +1,29 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct PlayNextItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + var body: some View { + SFSymbolButton(systemName: "chevron.right.circle") + .onSelect { + videoPlayerManager.selectNextViewModel() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift new file mode 100644 index 00000000..b8f05750 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift @@ -0,0 +1,29 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct PlayPreviousItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + var body: some View { + SFSymbolButton(systemName: "chevron.left.circle") + .onSelect { + videoPlayerManager.selectPreviousViewModel() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift new file mode 100644 index 00000000..c43576b6 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleButton.swift @@ -0,0 +1,29 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct SubtitleButton: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + var body: some View { + SFSymbolButton(systemName: "captions.bubble") + .onSelect { + videoPlayerManager.selectPreviousViewModel() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift new file mode 100644 index 00000000..00885428 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift @@ -0,0 +1,78 @@ +// +// 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 + +// TODO: add subtitles button + +extension VideoPlayer.Overlay { + + struct BarActionButtons: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var autoPlayButton: some View { + if viewModel.item.type == .episode { + ActionButtons.AutoPlay() + } + } + + @ViewBuilder + private var chaptersButton: some View { + if viewModel.chapters.isNotEmpty { + ActionButtons.Chapters() + } + } + + @ViewBuilder + private var playNextItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayNextItem() + } + } + + @ViewBuilder + private var playPreviousItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayPreviousItem() + } + } + + @ViewBuilder + private var menuItemButton: some View { + SFSymbolButton( + systemName: "ellipsis.circle", + systemNameFocused: "ellipsis.circle.fill" + ) + .onSelect { + currentOverlayType = .smallMenu + } + .frame(maxWidth: 30, maxHeight: 30) + } + + var body: some View { + HStack { + playPreviousItemButton + + playNextItemButton + + autoPlayButton + + chaptersButton + + menuItemButton + } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift new file mode 100644 index 00000000..5e82a236 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift @@ -0,0 +1,110 @@ +// +// 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 VideoPlayer.Overlay { + + struct BottomBarView: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @FocusState + private var isBarFocused: Bool + + @ViewBuilder + private var playbackStateView: some View { + if videoPlayerManager.state == .playing { + Image(systemName: "pause.circle") + } else if videoPlayerManager.state == .paused { + Image(systemName: "play.circle") + } else { + ProgressView() + } + } + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { + + if let subtitle = viewModel.item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + } + + HStack { + + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.bold) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + BarActionButtons() + } + + tvOSSliderView(value: $currentProgressHandler.scrubbedProgress) + .onEditingChanged { isEditing in + isScrubbing = isEditing + + if isEditing { + overlayTimer.pause() + } else { + overlayTimer.start(5) + } + } + .focused($isBarFocused) + .frame(height: 60) +// .visible(isScrubbing || isPresentingOverlay) + + HStack(spacing: 15) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .monospacedDigit() + .foregroundColor(.white) + + playbackStateView + .frame(maxWidth: 40, maxHeight: 40) + + Spacer() + + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .monospacedDigit() + .foregroundColor(.white) + } + } + .onChange(of: isPresentingOverlay) { _, newValue in + guard newValue else { return } + } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift new file mode 100644 index 00000000..fb955169 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift @@ -0,0 +1,55 @@ +// +// 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 tvOSSliderView: UIViewRepresentable { + + @Binding + private var value: CGFloat + + private var onEditingChanged: (Bool) -> Void + + // TODO: look at adjusting value dependent on item runtime + private let maxValue: Double = 1000 + + func makeUIView(context: Context) -> UITVOSSlider { + let slider = UITVOSSlider( + value: _value, + onEditingChanged: onEditingChanged + ) + + slider.value = Float(value) + slider.minimumValue = 0 + slider.maximumValue = Float(maxValue) + slider.thumbSize = 25 + slider.thumbTintColor = .white + slider.minimumTrackTintColor = .white + slider.focusScaleFactor = 1.4 + slider.panDampingValue = 50 + slider.fineTunningVelocityThreshold = 1000 + + return slider + } + + func updateUIView(_ uiView: UITVOSSlider, context: Context) {} +} + +extension tvOSSliderView { + + init(value: Binding) { + self.init( + value: value, + onEditingChanged: { _ in } + ) + } + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift new file mode 100644 index 00000000..e2faadaa --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift @@ -0,0 +1,442 @@ +// +// 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 +// + +// Modification of https://github.com/zattoo/TvOSSlider + +import SwiftUI +import UIKit + +// TODO: Replace + +private let trackViewHeight: CGFloat = 5 +private let animationDuration: TimeInterval = 0.3 +private let defaultValue: Float = 0 +private let defaultMinimumValue: Float = 0 +private let defaultMaximumValue: Float = 1 +private let defaultIsContinuous: Bool = true +private let defaultThumbTintColor: UIColor = .white +private let defaultTrackColor: UIColor = .gray +private let defaultMininumTrackTintColor: UIColor = .blue +private let defaultFocusScaleFactor: CGFloat = 1.05 +private let defaultStepValue: Float = 0.1 +private let decelerationRate: Float = 0.92 +private let decelerationMaxVelocity: Float = 1000 + +/// A control used to select a single value from a continuous range of values. +final class UITVOSSlider: UIControl { + + /// The slider’s current value. + var value: Float { + get { + storedValue + } + set { + storedValue = min(maximumValue, newValue) + storedValue = max(minimumValue, storedValue) + + var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) + offset = min(trackView.bounds.width, offset) + thumbViewCenterXConstraint.constant = offset + } + } + + /// The minimum value of the slider. + var minimumValue: Float = defaultMinimumValue { + didSet { + value = max(value, minimumValue) + } + } + + /// The maximum value of the slider. + var maximumValue: Float = defaultMaximumValue { + didSet { + value = min(value, maximumValue) + } + } + + /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. + var isContinuous: Bool = defaultIsContinuous + + /// The color used to tint the default minimum track images. + var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { + didSet { + minimumTrackView.backgroundColor = minimumTrackTintColor + } + } + + /// The color used to tint the default maximum track images. + var maximumTrackTintColor: UIColor? { + didSet { + maximumTrackView.backgroundColor = maximumTrackTintColor + } + } + + /// The color used to tint the default thumb images. + var thumbTintColor: UIColor = defaultThumbTintColor { + didSet { + thumbView.backgroundColor = thumbTintColor + } + } + + /// Scale factor applied to the slider when receiving the focus + var focusScaleFactor: CGFloat = defaultFocusScaleFactor { + didSet { + updateStateDependantViews() + } + } + + /// Value added or subtracted from the current value on steps left or right updates + var stepValue: Float = defaultStepValue + + /// Damping value for panning gestures + var panDampingValue: Float = 5 + + // Size for thumb view + var thumbSize: CGFloat = 30 + + var fineTunningVelocityThreshold: Float = 600 + + /** + Sets the slider’s current value, allowing you to animate the change visually. + + - Parameters: + - value: The new value to assign to the value property + - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. + */ + func setValue(_ value: Float, animated: Bool) { + self.value = value + stopDeceleratingTimer() + + if animated { + UIView.animate(withDuration: animationDuration) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + } + + /** + Assigns a minimum track image to the specified control states. + + - Parameters: + - image: The minimum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { + minimumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a maximum track image to the specified control states. + + - Parameters: + - image: The maximum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { + maximumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a thumb image to the specified control states. + + - Parameters: + - image: The thumb image to associate with the specified states. + - state: The control state with which to associate the image. + */ + func setThumbImage(_ image: UIImage?, for state: UIControl.State) { + thumbViewImages[state.rawValue] = image + updateStateDependantViews() + } + + // MARK: - Initializers + + private var onEditingChanged: (Bool) -> Void + private var valueBinding: Binding + + init( + value: Binding, + onEditingChanged: @escaping (Bool) -> Void + ) { + self.onEditingChanged = onEditingChanged + self.valueBinding = value + + super.init(frame: .zero) + + setUpView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - UIControlStates + + /// :nodoc: + override var isEnabled: Bool { + didSet { + panGestureRecognizer.isEnabled = isEnabled + updateStateDependantViews() + } + } + + /// :nodoc: + override var isSelected: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override var isHighlighted: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + coordinator.addCoordinatedAnimations({ + self.updateStateDependantViews() + }, completion: nil) + } + + // MARK: - Private + + private typealias ControlState = UInt + + private var storedValue: Float = defaultValue + + private var thumbViewImages: [ControlState: UIImage] = [:] + private var thumbView: UIImageView! + + private var trackViewImages: [ControlState: UIImage] = [:] + private var trackView: UIImageView! + + private var minimumTrackViewImages: [ControlState: UIImage] = [:] + private var minimumTrackView: UIImageView! + + private var maximumTrackViewImages: [ControlState: UIImage] = [:] + private var maximumTrackView: UIImageView! + + private var panGestureRecognizer: UIPanGestureRecognizer! + private var leftTapGestureRecognizer: UITapGestureRecognizer! + private var rightTapGestureRecognizer: UITapGestureRecognizer! + + private var thumbViewCenterXConstraint: NSLayoutConstraint! + + private weak var deceleratingTimer: Timer? + private var deceleratingVelocity: Float = 0 + + private var thumbViewCenterXConstraintConstant: Float = 0 + + private func setUpView() { + setUpTrackView() + setUpMinimumTrackView() + setUpMaximumTrackView() + setUpThumbView() + + setUpTrackViewConstraints() + setUpMinimumTrackViewConstraints() + setUpMaximumTrackViewConstraints() + setUpThumbViewConstraints() + + setUpGestures() + + updateStateDependantViews() + } + + private func setUpThumbView() { + thumbView = UIImageView() + thumbView.layer.cornerRadius = thumbSize / 6 + thumbView.backgroundColor = thumbTintColor + addSubview(thumbView) + } + + private func setUpTrackView() { + trackView = UIImageView() + trackView.layer.cornerRadius = trackViewHeight / 2 + trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) + addSubview(trackView) + } + + private func setUpMinimumTrackView() { + minimumTrackView = UIImageView() + minimumTrackView.layer.cornerRadius = trackViewHeight / 2 + minimumTrackView.backgroundColor = minimumTrackTintColor + addSubview(minimumTrackView) + } + + private func setUpMaximumTrackView() { + maximumTrackView = UIImageView() + maximumTrackView.layer.cornerRadius = trackViewHeight / 2 + maximumTrackView.backgroundColor = maximumTrackTintColor + addSubview(maximumTrackView) + } + + private func setUpTrackViewConstraints() { + trackView.translatesAutoresizingMaskIntoConstraints = false + trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMinimumTrackViewConstraints() { + minimumTrackView.translatesAutoresizingMaskIntoConstraints = false + minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true + minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMaximumTrackViewConstraints() { + maximumTrackView.translatesAutoresizingMaskIntoConstraints = false + maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true + maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpThumbViewConstraints() { + thumbView.translatesAutoresizingMaskIntoConstraints = false + thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true + thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true + thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) + thumbViewCenterXConstraint.isActive = true + } + + private func setUpGestures() { + panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) + addGestureRecognizer(panGestureRecognizer) + + leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) + leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] + leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(leftTapGestureRecognizer) + + rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) + rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] + rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(rightTapGestureRecognizer) + } + + private func updateStateDependantViews() { + thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] + + if isFocused { + thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) + } else { + thumbView.transform = CGAffineTransform.identity + } + } + + @objc + private func handleDeceleratingTimer(timer: Timer) { + let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + + if isContinuous { + sendActions(for: .valueChanged) + } + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + deceleratingVelocity *= decelerationRate + if !isFocused || abs(deceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + valueBinding.wrappedValue = CGFloat(percent) + onEditingChanged(false) + } + + private func stopDeceleratingTimer() { + deceleratingTimer?.invalidate() + deceleratingTimer = nil + deceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { + let translation = recognizer.translation(in: self) + if abs(translation.y) > abs(translation.x) { + return true + } + return false + } + + // MARK: - Actions + + @objc + private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { + + guard !isVerticalGesture(panGestureRecognizer) else { return } + + let translation = Float(panGestureRecognizer.translation(in: self).x) + let velocity = Float(panGestureRecognizer.velocity(in: self).x) + + switch panGestureRecognizer.state { + case .began: + onEditingChanged(true) + + stopDeceleratingTimer() + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + case .changed: + let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + if isContinuous { + sendActions(for: .valueChanged) + } + + valueBinding.wrappedValue = CGFloat(percent) + case .ended, .cancelled: + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + if abs(velocity) > fineTunningVelocityThreshold { + let direction: Float = velocity > 0 ? 1 : -1 + deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity + deceleratingTimer = Timer.scheduledTimer( + timeInterval: 0.01, + target: self, + selector: #selector(handleDeceleratingTimer(timer:)), + userInfo: nil, + repeats: true + ) + } else { + onEditingChanged(false) + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func leftTapWasTriggered() { + // setValue(value-stepValue, animated: true) +// viewModel.playerOverlayDelegate?.didSelectBackward() + } + + @objc + private func rightTapWasTriggered() { + // setValue(value+stepValue, animated: true) +// viewModel.playerOverlayDelegate?.didSelectForward() + } +} diff --git a/jellypig tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift similarity index 100% rename from jellypig tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift rename to jellyflood tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift new file mode 100644 index 00000000..4f867d41 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift @@ -0,0 +1,53 @@ +// +// 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 VideoPlayer { + + struct MainOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + VStack { + + Spacer() + + VideoPlayer.Overlay.BottomBarView() + .padding() + .padding() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .environmentObject(overlayTimer) + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/Overlay.swift new file mode 100644 index 00000000..10360ec4 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/Overlay.swift @@ -0,0 +1,135 @@ +// +// 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 PreferencesView +import SwiftUI +import VLCUI + +extension VideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @EnvironmentObject + private var proxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + @State + private var confirmCloseWorkItem: DispatchWorkItem? + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + @StateObject + private var overlayTimer: TimerProxy = .init() + + @ViewBuilder + private var currentOverlay: some View { + switch currentOverlayType { + case .chapters: + ChapterOverlay() + case .confirmClose: + ConfirmCloseOverlay() + case .main: + MainOverlay() + case .smallMenu: + SmallMenuOverlay() + } + } + + var body: some View { + currentOverlay + .visible(isPresentingOverlay) + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .environmentObject(overlayTimer) + .onChange(of: isPresentingOverlay) { + if !isPresentingOverlay { + currentOverlayType = .main + } + } + .onChange(of: currentOverlayType) { _, newValue in + if [.smallMenu, .chapters].contains(newValue) { + overlayTimer.pause() + } else if isPresentingOverlay { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { _, isActive in + guard !isActive else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + .pressCommands { + PressCommandAction(title: L10n.back, press: .menu, action: menuPress) + PressCommandAction(title: L10n.playAndPause, press: .playPause) { + if videoPlayerManager.state == .playing { + videoPlayerManager.proxy.pause() + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = true + } + } else if videoPlayerManager.state == .paused { + videoPlayerManager.proxy.play() + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + } + PressCommandAction(title: L10n.pressDownForMenu, press: .upArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .downArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .leftArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .rightArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .select, action: arrowPress) + } + } + + func arrowPress() { + if isPresentingOverlay { return } + currentOverlayType = .main + overlayTimer.start(5) + withAnimation { + isPresentingOverlay = true + } + } + + func menuPress() { + overlayTimer.start(5) + confirmCloseWorkItem?.cancel() + + if isPresentingOverlay && currentOverlayType == .confirmClose { + proxy.stop() + router.dismissCoordinator() + } else if isPresentingOverlay && currentOverlayType == .smallMenu { + currentOverlayType = .main + } else { + withAnimation { + currentOverlayType = .confirmClose + isPresentingOverlay = true + } + + let task = DispatchWorkItem { + withAnimation { + isPresentingOverlay = false + overlayTimer.stop() + } + } + + confirmCloseWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/jellyflood tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift new file mode 100644 index 00000000..b5a611ab --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -0,0 +1,210 @@ +// +// 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 VideoPlayer { + + struct SmallMenuOverlay: View { + + enum MenuSection: String, Displayable { + case audio + case playbackSpeed + case subtitles + + var displayTitle: String { + switch self { + case .audio: + return L10n.audio + case .playbackSpeed: + return L10n.playbackSpeed + case .subtitles: + return L10n.subtitles + } + } + } + + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @FocusState + private var focusedSection: MenuSection? + + @State + private var lastFocusedSection: MenuSection = .subtitles + + @StateObject + private var focusGuide: FocusGuide = .init() + + @ViewBuilder + private var subtitleMenu: some View { + HStack { + ForEach(viewModel.subtitleStreams, id: \.self) { mediaStream in + Button { + videoPlayerManager.subtitleTrackIndex = mediaStream.index ?? -1 + videoPlayerManager.proxy.setSubtitleTrack(.absolute(mediaStream.index ?? -1)) + } label: { + Label( + mediaStream.displayTitle ?? L10n.noTitle, + systemImage: videoPlayerManager.subtitleTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle" + ) + } + } + } + .modifier(MenuStyle(focusGuide: focusGuide)) + } + + @ViewBuilder + private var audioMenu: some View { + HStack { + ForEach(viewModel.audioStreams, id: \.self) { mediaStream in + Button { + videoPlayerManager.audioTrackIndex = mediaStream.index ?? -1 + videoPlayerManager.proxy.setAudioTrack(.absolute(mediaStream.index ?? -1)) + } label: { + Label( + mediaStream.displayTitle ?? L10n.noTitle, + systemImage: videoPlayerManager.audioTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle" + ) + } + } + } + .modifier(MenuStyle(focusGuide: focusGuide)) + } + + @ViewBuilder + private var playbackSpeedMenu: some View { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + videoPlayerManager.playbackSpeed = speed + videoPlayerManager.proxy.setRate(.absolute(Float(speed.rawValue))) + } label: { + Label( + speed.displayTitle, + systemImage: speed == videoPlayerManager.playbackSpeed ? "checkmark.circle.fill" : "circle" + ) + } + } + } + .modifier(MenuStyle(focusGuide: focusGuide)) + } + + var body: some View { + VStack { + + Spacer() + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if viewModel.subtitleStreams.isNotEmpty { + SectionButton( + section: .subtitles, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + } + + if viewModel.audioStreams.isNotEmpty { + SectionButton( + section: .audio, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + } + + SectionButton( + section: .playbackSpeed, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + } + .focusGuide( + focusGuide, + tag: "sections", + onContentFocus: { focusedSection = lastFocusedSection }, + bottom: "contents" + ) + .frame(height: 70) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + } + + ScrollView(.horizontal, showsIndicators: false) { + switch lastFocusedSection { + case .subtitles: + subtitleMenu + case .audio: + audioMenu + case .playbackSpeed: + playbackSpeedMenu + } + } + } + .ignoresSafeArea() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + .onChange(of: focusedSection) { _, newValue in + guard let newValue else { return } + lastFocusedSection = newValue + } + } + + struct SectionButton: View { + + let section: MenuSection + let focused: FocusState.Binding + let lastFocused: Binding + + var body: some View { + Button { + Text(section.displayTitle) + .fontWeight(.semibold) + .fixedSize() + .padding() + .if(lastFocused.wrappedValue == section) { text in + text + .background(.white) + .foregroundColor(.black) + } + } + .buttonStyle(.plain) + .background(.clear) + .focused(focused, equals: section) + } + } + + struct MenuStyle: ViewModifier { + var focusGuide: FocusGuide + + func body(content: Content) -> some View { + content + .focusGuide( + focusGuide, + tag: "contents", + top: "sections" + ) + .frame(height: 80) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + } + } + } +} diff --git a/jellyflood tvOS/Views/VideoPlayer/VideoPlayer.swift b/jellyflood tvOS/Views/VideoPlayer/VideoPlayer.swift new file mode 100644 index 00000000..0c41f574 --- /dev/null +++ b/jellyflood tvOS/Views/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,130 @@ +// +// 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 +import VLCUI + +struct VideoPlayer: View { + + enum OverlayType { + case chapters + case confirmClose + case main + case smallMenu + } + + @Environment(\.scenePhase) + private var scenePhase + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + + @ViewBuilder + private var playerView: some View { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator() + } + } + } + + VideoPlayer.Overlay() + .eraseToAnyView() + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { _, newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + } + + @ViewBuilder + private var loadingView: some View { + Text(L10n.retrievingMediaInformation) + } + + var body: some View { + ZStack { + + Color.black + + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + loadingView + } + } + .ignoresSafeArea() + .onChange(of: isScrubbing) { _, newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + .onScenePhase(.active) { + if Defaults[.VideoPlayer.Transition.playOnActive] { + videoPlayerManager.proxy.play() + } + } + .onScenePhase(.background) { + if Defaults[.VideoPlayer.Transition.pauseOnBackground] { + videoPlayerManager.proxy.pause() + } + } + } +} + +extension VideoPlayer { + + init(manager: VideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager + ) + } +} diff --git a/jellypig.xcodeproj/project.pbxproj b/jellyflood.xcodeproj/project.pbxproj similarity index 97% rename from jellypig.xcodeproj/project.pbxproj rename to jellyflood.xcodeproj/project.pbxproj index 2d88c34f..34541187 100644 --- a/jellypig.xcodeproj/project.pbxproj +++ b/jellyflood.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 21951AC22D9D2010002E03E0 /* AddUserBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */; }; 21BCDEF72D9C822000E1D180 /* AddUserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */; }; + 3F2549D9F1D55300F2FE6FD9 /* ConnectToXtreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0F04176EB2226FEC8B6E61 /* ConnectToXtreamView.swift */; }; 43D8DAACB1A6D59470D31082 /* EPGViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ED7169D761EBCCC34650B1 /* EPGViewModel.swift */; }; 4C0A02DD28ED5E02DDE52088 /* EPGProgramCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */; }; 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; @@ -101,7 +102,7 @@ 534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; - 535870632669D21600D05A09 /* jellypigapp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* jellypigapp.swift */; }; + 535870632669D21600D05A09 /* jellyfloodapp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* jellyfloodapp.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; 536803180875374165091699 /* EPGTimelineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */; }; @@ -180,7 +181,9 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; + 7E578BD57BFA515F69508441 /* DualServerConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE70B377462F05AAB0D7F9F /* DualServerConnectView.swift */; }; 9F6FDB6C675373491EB57B41 /* SeasonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2919FFF7C404A6AD31658B2 /* SeasonHStack.swift */; }; + B2CBD2FD6158B3DD3A974781 /* XtreamServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA60CE2CEEB8CC544F74DAC /* XtreamServer.swift */; }; B553DE52A96FE4289D1E6996 /* AttributeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65CB977628965AA9099742F /* AttributeBadge.swift */; }; B56E653D9B9A40ED072ED318 /* EPGCurrentTimeIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */; }; BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */; }; @@ -192,6 +195,7 @@ BDFF67B02D2CA59A009A9A3A /* UserLocalSecurityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF67AD2D2CA59A009A9A3A /* UserLocalSecurityView.swift */; }; BDFF67B22D2CA59A009A9A3A /* UserProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF67AE2D2CA59A009A9A3A /* UserProfileSettingsView.swift */; }; BDFF67B32D2CA99D009A9A3A /* UserProfileRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BE1CED2BDB68CD008176A9 /* UserProfileRow.swift */; }; + C377983BFD5E8B88EB1D3979 /* XtreamAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DDB64866F3C4D7EDCC4DDA /* XtreamAPIClient.swift */; }; C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; }; C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */; }; C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */; }; @@ -226,8 +230,8 @@ E10B1EBF2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; E10B1EC22BD9AD6100A92EAF /* V1UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */; }; E10B1EC82BD9AF6100A92EAF /* V2ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */; }; - E10B1ECB2BD9AF8200A92EAF /* jellypigstore+V1.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC92BD9AF8200A92EAF /* jellypigstore+V1.swift */; }; - E10B1ECE2BD9AFD800A92EAF /* jellypigstore+V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECC2BD9AFD800A92EAF /* jellypigstore+V2.swift */; }; + E10B1ECB2BD9AF8200A92EAF /* jellyfloodstore+V1.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC92BD9AF8200A92EAF /* jellyfloodstore+V1.swift */; }; + E10B1ECE2BD9AFD800A92EAF /* jellyfloodstore+V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECC2BD9AFD800A92EAF /* jellyfloodstore+V2.swift */; }; E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */; }; E10E67B62CF515130095365B /* Binding.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E67B52CF515130095365B /* Binding.swift */; }; E10E842A29A587110064EA49 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842929A587110064EA49 /* LoadingView.swift */; }; @@ -313,9 +317,9 @@ E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; - E1549661296CA2EF00C4EF88 /* jellypigdefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* jellypigdefaults.swift */; }; + E1549661296CA2EF00C4EF88 /* jellyflooddefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* jellyflooddefaults.swift */; }; E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* UserSession.swift */; }; - E1549665296CA2EF00C4EF88 /* jellypigstore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* jellypigstore.swift */; }; + E1549665296CA2EF00C4EF88 /* jellyfloodstore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* jellyfloodstore.swift */; }; E1549667296CA2EF00C4EF88 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* Notifications.swift */; }; E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; }; E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965D296CA2EF00C4EF88 /* LogManager.swift */; }; @@ -389,7 +393,7 @@ E1763A2B2BF3046E004DF6AB /* UserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */; }; E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */; }; E1763A6A2BF3D177004DF6AB /* PublicUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A692BF3D177004DF6AB /* PublicUserButton.swift */; }; - E1763A722BF3F67C004DF6AB /* jellypigstore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* jellypigstore+Mappings.swift */; }; + E1763A722BF3F67C004DF6AB /* jellyfloodstore+Mappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A702BF3F67C004DF6AB /* jellyfloodstore+Mappings.swift */; }; E1763A762BF3FF01004DF6AB /* AppLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; @@ -448,8 +452,8 @@ E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */; }; E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; - E1AEFA382BE36C4900CFAFD8 /* jellypigstore+UserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4C2BE1688E003BF6F3 /* jellypigstore+UserState.swift */; }; - E1AEFA392BE36C4C00CFAFD8 /* jellypigstore+ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4E2BE168AC003BF6F3 /* jellypigstore+ServerState.swift */; }; + E1AEFA382BE36C4900CFAFD8 /* jellyfloodstore+UserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4C2BE1688E003BF6F3 /* jellyfloodstore+UserState.swift */; }; + E1AEFA392BE36C4C00CFAFD8 /* jellyfloodstore+ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E145EB4E2BE168AC003BF6F3 /* jellyfloodstore+ServerState.swift */; }; E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */; }; @@ -485,7 +489,7 @@ E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */; }; E1CB757D2C80F00D00217C76 /* TranscodingProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */; }; E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */; }; - E1CB75832C80F66900217C76 /* VideoPlayerType+jellypig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75812C80F66900217C76 /* VideoPlayerType+jellypig.swift */; }; + E1CB75832C80F66900217C76 /* VideoPlayerType+jellyflood.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75812C80F66900217C76 /* VideoPlayerType+jellyflood.swift */; }; E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F472B9C648E00343D2B /* MaxHeightText.swift */; }; @@ -539,6 +543,7 @@ E1FE28CA2DC16B2B00E1A23E /* RedrawOnNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE28C82DC16B2B00E1A23E /* RedrawOnNotificationView.swift */; }; E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; E43918672AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */; }; + EAB00C1426C629373A8ECA66 /* ConnectToXtreamViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F8DE7C1FA6624C7247C9A2 /* ConnectToXtreamViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -556,12 +561,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 02DDB64866F3C4D7EDCC4DDA /* XtreamAPIClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = XtreamAPIClient.swift; sourceTree = ""; }; 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; - 0BFAA71C5194626E96E5F838 /* EPGChannelRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGChannelRow.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift"; sourceTree = ""; }; + 0BFAA71C5194626E96E5F838 /* EPGChannelRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGChannelRow.swift; path = "../jellyflood tvOS/Views/ProgramGuideView/Components/EPGChannelRow.swift"; sourceTree = ""; }; 12C80CEDC871A21D98141BBE /* VideoRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRangeType.swift; sourceTree = ""; }; + 1CE70B377462F05AAB0D7F9F /* DualServerConnectView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DualServerConnectView.swift; sourceTree = ""; }; 21951AC12D9D2010002E03E0 /* AddUserBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserBottomButton.swift; sourceTree = ""; }; 21BCDEF62D9C822000E1D180 /* AddUserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserGridButton.swift; sourceTree = ""; }; - 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGTimelineHeader.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift"; sourceTree = ""; }; + 48E6BA368CDEEDAD2A877B96 /* EPGTimelineHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGTimelineHeader.swift; path = "../jellyflood tvOS/Views/ProgramGuideView/Components/EPGTimelineHeader.swift"; sourceTree = ""; }; 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; @@ -789,8 +796,8 @@ 534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; 534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; 534D4FEF26A7D7CC000A7A48 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = Localizable.strings; sourceTree = ""; }; - 535870602669D21600D05A09 /* jellypig tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "jellypig tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 535870622669D21600D05A09 /* jellypigapp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = jellypigapp.swift; sourceTree = ""; }; + 535870602669D21600D05A09 /* jellyflood tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "jellyflood tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 535870622669D21600D05A09 /* jellyfloodapp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = jellyfloodapp.swift; sourceTree = ""; }; 535870662669D21700D05A09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterCollection.swift; sourceTree = ""; }; @@ -813,7 +820,7 @@ 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPerson.swift; sourceTree = ""; }; - 5377CBF4263B596A003A4E83 /* jellypigapp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = jellypigapp.swift; sourceTree = ""; }; + 5377CBF4263B596A003A4E83 /* jellyfloodapp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = jellyfloodapp.swift; sourceTree = ""; }; 5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; @@ -926,7 +933,9 @@ 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectAuthorizeViewModel.swift; sourceTree = ""; }; 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UDPBroadcast.xcframework; path = Carthage/Build/UDPBroadcast.xcframework; sourceTree = ""; }; 67ED7169D761EBCCC34650B1 /* EPGViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGViewModel.swift; path = ../Shared/ViewModels/EPGViewModel.swift; sourceTree = ""; }; - B2C1FE779A04986789C1D943 /* ProgramGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProgramGuideView.swift; path = "../jellypig tvOS/Views/ProgramGuideView.swift"; sourceTree = ""; }; + AC0F04176EB2226FEC8B6E61 /* ConnectToXtreamView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectToXtreamView.swift; sourceTree = ""; }; + B2C1FE779A04986789C1D943 /* ProgramGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProgramGuideView.swift; path = "../jellyflood tvOS/Views/ProgramGuideView.swift"; sourceTree = ""; }; + B2F8DE7C1FA6624C7247C9A2 /* ConnectToXtreamViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectToXtreamViewModel.swift; sourceTree = ""; }; B65CB977628965AA9099742F /* AttributeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeBadge.swift; sourceTree = ""; }; BD0BA22A2AD6503B00306A8D /* OnlineVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineVideoPlayerManager.swift; sourceTree = ""; }; BD0BA22D2AD6508C00306A8D /* DownloadVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadVideoPlayerManager.swift; sourceTree = ""; }; @@ -958,7 +967,7 @@ C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = ""; }; C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = ""; }; C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; - C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGProgramCell.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift"; sourceTree = ""; }; + C527D44A53C7443E836A3DAD /* EPGProgramCell.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGProgramCell.swift; path = "../jellyflood tvOS/Views/ProgramGuideView/Components/EPGProgramCell.swift"; sourceTree = ""; }; DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInState.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailViewModel.swift; sourceTree = ""; }; @@ -994,8 +1003,8 @@ E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1ServerModel.swift; sourceTree = ""; }; E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1UserModel.swift; sourceTree = ""; }; E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ServerModel.swift; sourceTree = ""; }; - E10B1EC92BD9AF8200A92EAF /* jellypigstore+V1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigstore+V1.swift"; sourceTree = ""; }; - E10B1ECC2BD9AFD800A92EAF /* jellypigstore+V2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigstore+V2.swift"; sourceTree = ""; }; + E10B1EC92BD9AF8200A92EAF /* jellyfloodstore+V1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodstore+V1.swift"; sourceTree = ""; }; + E10B1ECC2BD9AFD800A92EAF /* jellyfloodstore+V2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodstore+V2.swift"; sourceTree = ""; }; E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2UserModel.swift; sourceTree = ""; }; E10E67B52CF515130095365B /* Binding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.swift; sourceTree = ""; }; E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; @@ -1078,7 +1087,7 @@ E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterOverlay.swift; sourceTree = ""; }; E139CC1E28EC83E400688DE2 /* Int.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int.swift; sourceTree = ""; }; - E13D02842788B634000FCB04 /* jellypig.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = jellypig.entitlements; sourceTree = ""; }; + E13D02842788B634000FCB04 /* jellyflood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = jellyflood.entitlements; sourceTree = ""; }; E13D98EC2D0664C1005FE96D /* NotificationSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSet.swift; sourceTree = ""; }; E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E13DD3C727164B1E009D4DAF /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; @@ -1102,8 +1111,8 @@ E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionMenu.swift; sourceTree = ""; }; E145EB442BE0AD4E003BF6F3 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; E145EB472BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollIfLargerThanContainerModifier.swift; sourceTree = ""; }; - E145EB4C2BE1688E003BF6F3 /* jellypigstore+UserState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigstore+UserState.swift"; sourceTree = ""; }; - E145EB4E2BE168AC003BF6F3 /* jellypigstore+ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigstore+ServerState.swift"; sourceTree = ""; }; + E145EB4C2BE1688E003BF6F3 /* jellyfloodstore+UserState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodstore+UserState.swift"; sourceTree = ""; }; + E145EB4E2BE168AC003BF6F3 /* jellyfloodstore+ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodstore+ServerState.swift"; sourceTree = ""; }; E146A9D72BE6E9830034DA1E /* StoredValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredValue.swift; sourceTree = ""; }; E146A9DA2BE6E9BF0034DA1E /* StoredValues+User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredValues+User.swift"; sourceTree = ""; }; E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SortOrder+ItemSortOrder.swift"; sourceTree = ""; }; @@ -1121,9 +1130,9 @@ E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; E1549655296CA2EF00C4EF88 /* DownloadTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadTask.swift; sourceTree = ""; }; - E1549656296CA2EF00C4EF88 /* jellypigdefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = jellypigdefaults.swift; sourceTree = ""; }; + E1549656296CA2EF00C4EF88 /* jellyflooddefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = jellyflooddefaults.swift; sourceTree = ""; }; E1549657296CA2EF00C4EF88 /* UserSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; - E1549658296CA2EF00C4EF88 /* jellypigstore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = jellypigstore.swift; sourceTree = ""; }; + E1549658296CA2EF00C4EF88 /* jellyfloodstore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = jellyfloodstore.swift; sourceTree = ""; }; E1549659296CA2EF00C4EF88 /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; E154965B296CA2EF00C4EF88 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; E154965D296CA2EF00C4EF88 /* LogManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; @@ -1171,7 +1180,7 @@ E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserGridButton.swift; sourceTree = ""; }; E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowButton.swift; sourceTree = ""; }; E1763A692BF3D177004DF6AB /* PublicUserButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserButton.swift; sourceTree = ""; }; - E1763A702BF3F67C004DF6AB /* jellypigstore+Mappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigstore+Mappings.swift"; sourceTree = ""; }; + E1763A702BF3F67C004DF6AB /* jellyfloodstore+Mappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodstore+Mappings.swift"; sourceTree = ""; }; E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; E1763A752BF3FF01004DF6AB /* AppLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoadingView.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; @@ -1278,7 +1287,7 @@ E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = ""; }; - E1BAFE0F2BE921270069C4D7 /* jellypigApp+ValueObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellypigApp+ValueObservation.swift"; sourceTree = ""; }; + E1BAFE0F2BE921270069C4D7 /* jellyfloodApp+ValueObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "jellyfloodApp+ValueObservation.swift"; sourceTree = ""; }; E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordViewModel.swift; sourceTree = ""; }; E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = ""; }; @@ -1323,7 +1332,7 @@ E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Native.swift"; sourceTree = ""; }; E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodingProfile.swift; sourceTree = ""; }; E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleProfile.swift; sourceTree = ""; }; - E1CB75812C80F66900217C76 /* VideoPlayerType+jellypig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+jellypig.swift"; sourceTree = ""; }; + E1CB75812C80F66900217C76 /* VideoPlayerType+jellyflood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+jellyflood.swift"; sourceTree = ""; }; E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecProfile.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; @@ -1419,7 +1428,8 @@ E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScenePhaseChangedModifier.swift; sourceTree = ""; }; - E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGCurrentTimeIndicator.swift; path = "../jellypig tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift"; sourceTree = ""; }; + E89069FCB78E1759D27632E1 /* EPGCurrentTimeIndicator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EPGCurrentTimeIndicator.swift; path = "../jellyflood tvOS/Views/ProgramGuideView/Components/EPGCurrentTimeIndicator.swift"; sourceTree = ""; }; + FAA60CE2CEEB8CC544F74DAC /* XtreamServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = XtreamServer.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1723,6 +1733,8 @@ children = ( 4E4DAC3B2D11F69000E13FF9 /* Components */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, + AC0F04176EB2226FEC8B6E61 /* ConnectToXtreamView.swift */, + 1CE70B377462F05AAB0D7F9F /* DualServerConnectView.swift */, ); path = ConnectToServerView; sourceTree = ""; @@ -2512,6 +2524,7 @@ BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, + B2F8DE7C1FA6624C7247C9A2 /* ConnectToXtreamViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -2592,7 +2605,7 @@ path = "zh-Hans.lproj"; sourceTree = ""; }; - 535870612669D21600D05A09 /* jellypig tvOS */ = { + 535870612669D21600D05A09 /* jellyflood tvOS */ = { isa = PBXGroup; children = ( E12186DF2718F2030010884C /* App */, @@ -2602,7 +2615,7 @@ E1DABAD62A26E28E008AC34A /* Resources */, E12186E02718F23B0010884C /* Views */, ); - path = "jellypig tvOS"; + path = "jellyflood tvOS"; sourceTree = ""; }; 535870752669D60C00D05A09 /* Shared */ = { @@ -2617,7 +2630,7 @@ 091B5A852683142E00D78B61 /* ServerDiscovery */, E1549654296CA2EF00C4EF88 /* Services */, 6286F09F271C0AA500C40ED5 /* Strings */, - E10B1EB72BD9ACC800A92EAF /* jellypigstore */, + E10B1EB72BD9ACC800A92EAF /* jellyfloodstore */, 532175392671BCED005491E6 /* ViewModels */, ); path = Shared; @@ -2678,6 +2691,7 @@ E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, E1CB757A2C80EF9D00217C76 /* VideoPlayerType */, + FAA60CE2CEEB8CC544F74DAC /* XtreamServer.swift */, ); path = Objects; sourceTree = ""; @@ -2710,8 +2724,8 @@ 5377CBE8263B596A003A4E83 = { isa = PBXGroup; children = ( - 5377CBF3263B596A003A4E83 /* jellypig */, - 535870612669D21600D05A09 /* jellypig tvOS */, + 5377CBF3263B596A003A4E83 /* jellyflood */, + 535870612669D21600D05A09 /* jellyflood tvOS */, 535870752669D60C00D05A09 /* Shared */, 534D4FE126A7D7CC000A7A48 /* Translations */, 5377CBF2263B596A003A4E83 /* Products */, @@ -2723,12 +2737,12 @@ 5377CBF2263B596A003A4E83 /* Products */ = { isa = PBXGroup; children = ( - 535870602669D21600D05A09 /* jellypig tvOS.app */, + 535870602669D21600D05A09 /* jellyflood tvOS.app */, ); name = Products; sourceTree = ""; }; - 5377CBF3263B596A003A4E83 /* jellypig */ = { + 5377CBF3263B596A003A4E83 /* jellyflood */ = { isa = PBXGroup; children = ( E13DD3BB27163C3E009D4DAF /* App */, @@ -2738,9 +2752,9 @@ E1DCDE3B2A2D134000FA9C91 /* Resources */, E13DD3D027165886009D4DAF /* Views */, A9B355418BEC3896FA6490EA /* Shared */, - 7921755ED42A950421AEEF8E /* jellypig tvOS */, + 7921755ED42A950421AEEF8E /* jellyflood tvOS */, ); - path = jellypig; + path = jellyflood; sourceTree = ""; }; 53913BC826D323FE00EB3286 /* fr.lproj */ = { @@ -3311,12 +3325,12 @@ name = Views; sourceTree = ""; }; - 7921755ED42A950421AEEF8E /* jellypig tvOS */ = { + 7921755ED42A950421AEEF8E /* jellyflood tvOS */ = { isa = PBXGroup; children = ( 6C70C653BDDF18CB02C02D82 /* Views */, ); - name = "jellypig tvOS"; + name = "jellyflood tvOS"; sourceTree = ""; }; 7D7484DE244C0DAFD185432A /* ProgramGuideView */ = { @@ -3581,24 +3595,24 @@ path = Components; sourceTree = ""; }; - E10B1EB72BD9ACC800A92EAF /* jellypigstore */ = { + E10B1EB72BD9ACC800A92EAF /* jellyfloodstore */ = { isa = PBXGroup; children = ( E146A9DD2BE6E9DC0034DA1E /* StoredValue */, - E1549658296CA2EF00C4EF88 /* jellypigstore.swift */, - E1763A702BF3F67C004DF6AB /* jellypigstore+Mappings.swift */, - E145EB4E2BE168AC003BF6F3 /* jellypigstore+ServerState.swift */, - E145EB4C2BE1688E003BF6F3 /* jellypigstore+UserState.swift */, + E1549658296CA2EF00C4EF88 /* jellyfloodstore.swift */, + E1763A702BF3F67C004DF6AB /* jellyfloodstore+Mappings.swift */, + E145EB4E2BE168AC003BF6F3 /* jellyfloodstore+ServerState.swift */, + E145EB4C2BE1688E003BF6F3 /* jellyfloodstore+UserState.swift */, E10B1EB82BD9ACE900A92EAF /* V1Schema */, E10B1EB92BD9ACFB00A92EAF /* V2Schema */, ); - path = jellypigstore; + path = jellyfloodstore; sourceTree = ""; }; E10B1EB82BD9ACE900A92EAF /* V1Schema */ = { isa = PBXGroup; children = ( - E10B1EC92BD9AF8200A92EAF /* jellypigstore+V1.swift */, + E10B1EC92BD9AF8200A92EAF /* jellyfloodstore+V1.swift */, E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */, E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */, ); @@ -3608,7 +3622,7 @@ E10B1EB92BD9ACFB00A92EAF /* V2Schema */ = { isa = PBXGroup; children = ( - E10B1ECC2BD9AFD800A92EAF /* jellypigstore+V2.swift */, + E10B1ECC2BD9AFD800A92EAF /* jellyfloodstore+V2.swift */, E164A8142BE58C2F00A54B18 /* V2AnyData.swift */, E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */, E10B1ECF2BD9AFF200A92EAF /* V2UserModel.swift */, @@ -3741,7 +3755,7 @@ E12186DF2718F2030010884C /* App */ = { isa = PBXGroup; children = ( - 535870622669D21600D05A09 /* jellypigapp.swift */, + 535870622669D21600D05A09 /* jellyfloodapp.swift */, E1388A44293F0AB1009721B1 /* PreferenceUIHosting */, ); path = App; @@ -3808,8 +3822,8 @@ isa = PBXGroup; children = ( E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, - 5377CBF4263B596A003A4E83 /* jellypigapp.swift */, - E1BAFE0F2BE921270069C4D7 /* jellypigApp+ValueObservation.swift */, + 5377CBF4263B596A003A4E83 /* jellyfloodapp.swift */, + E1BAFE0F2BE921270069C4D7 /* jellyfloodApp+ValueObservation.swift */, ); path = App; sourceTree = ""; @@ -3942,8 +3956,9 @@ E19D41A92BF077130082B8B2 /* Keychain.swift */, E154965D296CA2EF00C4EF88 /* LogManager.swift */, E1549659296CA2EF00C4EF88 /* Notifications.swift */, - E1549656296CA2EF00C4EF88 /* jellypigdefaults.swift */, + E1549656296CA2EF00C4EF88 /* jellyflooddefaults.swift */, E1549657296CA2EF00C4EF88 /* UserSession.swift */, + 02DDB64866F3C4D7EDCC4DDA /* XtreamAPIClient.swift */, ); path = Services; sourceTree = ""; @@ -4470,7 +4485,7 @@ 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */, E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */, E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */, - E1CB75812C80F66900217C76 /* VideoPlayerType+jellypig.swift */, + E1CB75812C80F66900217C76 /* VideoPlayerType+jellyflood.swift */, ); path = VideoPlayerType; sourceTree = ""; @@ -4552,7 +4567,7 @@ children = ( 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5377CC02263B596B003A4E83 /* Info.plist */, - E13D02842788B634000FCB04 /* jellypig.entitlements */, + E13D02842788B634000FCB04 /* jellyflood.entitlements */, ); path = Resources; sourceTree = ""; @@ -4697,9 +4712,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 5358705F2669D21600D05A09 /* jellypig tvOS */ = { + 5358705F2669D21600D05A09 /* jellyflood tvOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "jellypig tvOS" */; + buildConfigurationList = 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "jellyflood tvOS" */; buildPhases = ( 4EC71FBD2D1620AF00D0B3A8 /* Alphabetize Strings */, 6286F0A3271C0ABA00C40ED5 /* Run Swiftgen.swift */, @@ -4713,7 +4728,7 @@ ); dependencies = ( ); - name = "jellypig tvOS"; + name = "jellyflood tvOS"; packageProductDependencies = ( 6220D0C826D63F3700B8E046 /* Stinsen */, E13DD3CC27164CA7009D4DAF /* CoreStore */, @@ -4743,7 +4758,7 @@ BD88CB412D77E6A0006BB5E3 /* TVOSPicker */, ); productName = "JellyfinPlayer tvOS"; - productReference = 535870602669D21600D05A09 /* jellypig tvOS.app */; + productReference = 535870602669D21600D05A09 /* jellyflood tvOS.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -4764,7 +4779,7 @@ }; }; }; - buildConfigurationList = 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "jellypig" */; + buildConfigurationList = 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "jellyflood" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; @@ -4848,7 +4863,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 5358705F2669D21600D05A09 /* jellypig tvOS */, + 5358705F2669D21600D05A09 /* jellyflood tvOS */, ); }; /* End PBXProject section */ @@ -4976,7 +4991,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E1AEFA392BE36C4C00CFAFD8 /* jellypigstore+ServerState.swift in Sources */, + E1AEFA392BE36C4C00CFAFD8 /* jellyfloodstore+ServerState.swift in Sources */, E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */, 21BCDEF72D9C822000E1D180 /* AddUserGridButton.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, @@ -5054,7 +5069,7 @@ E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, E1E6C45629B130F50064123F /* ChapterOverlay.swift in Sources */, - E1549665296CA2EF00C4EF88 /* jellypigstore.swift in Sources */, + E1549665296CA2EF00C4EF88 /* jellyfloodstore.swift in Sources */, E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, 4E2AC4C32C6C491200DD600D /* AudoCodec.swift in Sources */, @@ -5103,7 +5118,7 @@ E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, E11982BB2DA05FF50008FC3F /* CenteredLazyVGrid.swift in Sources */, C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */, - E1549661296CA2EF00C4EF88 /* jellypigdefaults.swift in Sources */, + E1549661296CA2EF00C4EF88 /* jellyflooddefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, 4E01446C2D0292E200193038 /* Trie.swift in Sources */, @@ -5206,8 +5221,8 @@ 4E17498F2CC00A3100DD07D1 /* DeviceInfoDto.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */, - E10B1ECE2BD9AFD800A92EAF /* jellypigstore+V2.swift in Sources */, - E1763A722BF3F67C004DF6AB /* jellypigstore+Mappings.swift in Sources */, + E10B1ECE2BD9AFD800A92EAF /* jellyfloodstore+V2.swift in Sources */, + E1763A722BF3F67C004DF6AB /* jellyfloodstore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */, @@ -5322,7 +5337,7 @@ E1EA096A2BED78F5004CDE76 /* UserAccessPolicy.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, - E1AEFA382BE36C4900CFAFD8 /* jellypigstore+UserState.swift in Sources */, + E1AEFA382BE36C4900CFAFD8 /* jellyfloodstore+UserState.swift in Sources */, E10E67B62CF515130095365B /* Binding.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, 4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */, @@ -5362,10 +5377,10 @@ 4E1AA0052D0640AA00524970 /* RemoteImageInfo.swift in Sources */, E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, - E1CB75832C80F66900217C76 /* VideoPlayerType+jellypig.swift in Sources */, - E10B1ECB2BD9AF8200A92EAF /* jellypigstore+V1.swift in Sources */, + E1CB75832C80F66900217C76 /* VideoPlayerType+jellyflood.swift in Sources */, + E10B1ECB2BD9AF8200A92EAF /* jellyfloodstore+V1.swift in Sources */, E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, - 535870632669D21600D05A09 /* jellypigapp.swift in Sources */, + 535870632669D21600D05A09 /* jellyfloodapp.swift in Sources */, E1D90D772C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */, E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */, E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, @@ -5418,6 +5433,11 @@ 536803180875374165091699 /* EPGTimelineHeader.swift in Sources */, B56E653D9B9A40ED072ED318 /* EPGCurrentTimeIndicator.swift in Sources */, 527E650F94265266C416CFD8 /* ProgramGuideView.swift in Sources */, + B2CBD2FD6158B3DD3A974781 /* XtreamServer.swift in Sources */, + C377983BFD5E8B88EB1D3979 /* XtreamAPIClient.swift in Sources */, + EAB00C1426C629373A8ECA66 /* ConnectToXtreamViewModel.swift in Sources */, + 3F2549D9F1D55300F2FE6FD9 /* ConnectToXtreamView.swift in Sources */, + 7E578BD57BFA515F69508441 /* DualServerConnectView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5800,13 +5820,13 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - INFOPLIST_FILE = "jellypig tvOS/Resources/Info.plist"; + INFOPLIST_FILE = "jellyflood tvOS/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.ashik.jellypig; + PRODUCT_BUNDLE_IDENTIFIER = se.ashik.jellyflood; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -5830,13 +5850,13 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - INFOPLIST_FILE = "jellypig tvOS/Resources/Info.plist"; + INFOPLIST_FILE = "jellyflood tvOS/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.ashik.jellypig; + PRODUCT_BUNDLE_IDENTIFIER = se.ashik.jellyflood; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -5976,7 +5996,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "jellypig tvOS" */ = { + 535870712669D21700D05A09 /* Build configuration list for PBXNativeTarget "jellyflood tvOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 535870722669D21700D05A09 /* Debug */, @@ -5985,7 +6005,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "jellypig" */ = { + 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "jellyflood" */ = { isa = XCConfigurationList; buildConfigurations = ( 5377CC19263B596B003A4E83 /* Debug */, diff --git a/jellyflood.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/jellyflood.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/jellyflood.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/jellyflood.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/jellyflood.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/jellyflood.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/jellypig.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/jellyflood.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from jellypig.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to jellyflood.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/jellyflood.xcodeproj/xcshareddata/IDETemplateMacros.plist b/jellyflood.xcodeproj/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 00000000..86da1185 --- /dev/null +++ b/jellyflood.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,14 @@ + + + + + FILEHEADER + +// 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) ___YEAR___ Jellyfin & Jellyfin Contributors +// + + diff --git a/jellyflood.xcodeproj/xcshareddata/xcschemes/jellyflood tvOS.xcscheme b/jellyflood.xcodeproj/xcshareddata/xcschemes/jellyflood tvOS.xcscheme new file mode 100644 index 00000000..71fed7e2 --- /dev/null +++ b/jellyflood.xcodeproj/xcshareddata/xcschemes/jellyflood tvOS.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/remove_epg_files.rb b/remove_epg_files.rb new file mode 100644 index 00000000..5a0b8d16 --- /dev/null +++ b/remove_epg_files.rb @@ -0,0 +1,41 @@ +#!/usr/bin/env ruby +require 'xcodeproj' + +project_path = 'jellypig.xcodeproj' +project = Xcodeproj::Project.open(project_path) + +# Find the main target +target = project.targets.find { |t| t.name == 'jellypig tvOS' } + +# File names to remove +file_names = [ + 'EPGViewModel.swift', + 'EPGProgramCell.swift', + 'EPGChannelRow.swift', + 'EPGTimelineHeader.swift', + 'EPGCurrentTimeIndicator.swift' +] + +file_names.each do |file_name| + # Find and remove from all groups + project.main_group.recursive_children.each do |child| + if child.is_a?(Xcodeproj::Project::Object::PBXFileReference) && child.path == file_name + # Remove from build phase + target.source_build_phase.files.each do |build_file| + if build_file.file_ref == child + target.source_build_phase.files.delete(build_file) + puts "Removed from build phase: #{file_name}" + end + end + + # Remove file reference + child.remove_from_project + puts "Removed reference: #{file_name}" + end + end +end + +# Save the project +project.save + +puts "\nCleanup completed!" diff --git a/swiftgen.yml b/swiftgen.yml index 4e476234..f7214df2 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -5,7 +5,7 @@ strings: output: Shared/Strings/Strings.swift xcassets: - inputs: "jellypig tvOS/Resources/Assets.xcassets" + inputs: "jellyflood tvOS/Resources/Assets.xcassets" outputs: - templateName: swift5 output: Shared/Generated/Images+Generated.swift