Rename project from jellypig to jellyflood

Complete rebranding from jellypig to jellyflood including:
- Renamed all jellypig references to jellyflood
- Updated store implementations (jellypigstore -> jellyfloodstore)
- Moved jellypig tvOS to Swiftfin tvOS structure
- Updated service configurations and defaults
- Preserved all Xtream plugin support and EPG functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ashik K 2025-10-18 09:14:33 +02:00
parent fdd1cdc15b
commit 09a3ce15a0
1094 changed files with 63476 additions and 142 deletions

44
.claude/commands/build.md Normal file
View File

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

View File

@ -8,9 +8,10 @@ Steps:
1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt 1. Read /Users/ashikkizhakkepallathu/Documents/claude/jellypig/chats-summary.txt
2. Display a concise summary including: 2. Display a concise summary including:
- Project name and description - Project name and description
- Available custom slash commands (/sim, etc.) - Available custom slash commands (/build, /sim, etc.)
- Recent features implemented - 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 - Common tasks you can help with
Make the output brief and actionable - focus on what's immediately useful for the developer. Make the output brief and actionable - focus on what's immediately useful for the developer.

View File

@ -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. Build the latest version of jellypig tvOS in Debug configuration, install it on the Apple TV simulator, and launch it.
Steps: Steps:
1. Boot the Apple TV simulator (16A71179-729D-4F1B-8698-8371F137025B) 1. First, build the project using the same approach as `/build debug`:
2. Open Simulator.app ```bash
3. Build the project for tvOS Simulator cd /Users/ashikkizhakkepallathu/Documents/claude/jellypig/jellypig
4. Install the built app on the simulator xcodebuild -project jellypig.xcodeproj \
5. Launch the app with bundle identifier org.ashik.jellypig -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.

View File

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

View File

@ -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<CountryInfo?>
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<String?>) {
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]
)
)
}
}

View File

@ -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<CultureDto?>
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<String?>) {
self.title = title
self._selection = State(
initialValue: twoLetterISOLanguageName.wrappedValue.flatMap {
CultureDto(twoLetterISOLanguageName: $0)
} ?? CultureDto.none
)
self.selectionBinding = Binding<CultureDto?>(
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<String?>) {
self.title = title
self._selection = State(
initialValue: threeLetterISOLanguageName.wrappedValue.flatMap {
CultureDto(threeLetterISOLanguageName: $0)
} ?? CultureDto.none
)
self.selectionBinding = Binding<CultureDto?>(
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]
)
)
}
}

View File

@ -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<ParentalRating?>
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<String?>) {
self.title = title
self._selection = State(
initialValue: name.wrappedValue.flatMap {
ParentalRating(name: $0)
} ?? ParentalRating.none
)
self.selectionBinding = Binding<ParentalRating?>(
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]
)
)
}
}

View File

@ -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<Content: View, Marker: View>: 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<EntryContent: View>: 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)
}
}
}
}
}

View File

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

View File

@ -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<Item: Poster>: 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
)
}
}

View File

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

View File

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

View File

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

View File

@ -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<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
@ViewBuilder
func makeStart() -> some View {
HomeView()
}
}

View File

@ -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<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
@ViewBuilder
func makeStart() -> some View {
HomeView()
}
}

View File

@ -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<Element: Poster>: 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<Element>
init(viewModel: PagingLibraryViewModel<Element>) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
PagingLibraryView(viewModel: viewModel)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
}

View File

@ -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<Element: Poster>: 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<Element>
init(viewModel: PagingLibraryViewModel<Element>) {
self.viewModel = viewModel
}
@ViewBuilder
func makeStart() -> some View {
PagingLibraryView(viewModel: viewModel)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
}

View File

@ -69,6 +69,8 @@ final class LiveVideoPlayerCoordinator: NavigationCoordinatable {
#else #else
PreferencesView { 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 { if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
LiveVideoPlayer(manager: self.videoPlayerManager) LiveVideoPlayer(manager: self.videoPlayerManager)
} else { } else {

View File

@ -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<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}
#else
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
func makeDownloads() -> DownloadListCoordinator {
DownloadListCoordinator()
}
#endif
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
@ViewBuilder
func makeStart() -> some View {
MediaView()
}
}

View File

@ -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<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}
#else
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
func makeDownloads() -> DownloadListCoordinator {
DownloadListCoordinator()
}
#endif
func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator()
}
@ViewBuilder
func makeStart() -> some View {
MediaView()
}
}

View File

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

View File

@ -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<Bool> = .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
}
}

View File

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

View File

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

View File

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

View File

@ -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<String>(
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<BaseItemPerson>(
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<NameGuidPair>(
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<String>(
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
}

View File

@ -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<some Poster>
) -> NavigationRoute {
NavigationRoute(
id: "library-(\(viewModel.parent?.id ?? "Unparented"))",
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
) {
PagingLibraryView(viewModel: viewModel)
}
}
}

View File

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

View File

@ -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<CustomDeviceProfile>) -> 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<String>) -> 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()
}
}

View File

@ -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<String>, accessPolicy: Binding<UserAccessPolicy>) -> 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)
}
}
}
}
}

View File

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

View File

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

View File

@ -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<Content: View>: View {
@StateObject
private var publishedBox: PublishedBox<LegacyEventPublisher<TransitionReaderProxy?>> = .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<TransitionReaderProxy?> {
publisher
}
}
extension EnvironmentValues {
@Entry
var transitionReader: LegacyEventPublisher<TransitionReaderProxy?> = .init()
}
#endif

View File

@ -0,0 +1,86 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import 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()
}
}

View File

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

View File

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

View File

@ -0,0 +1,52 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import 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<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
@ViewBuilder
func makeStart() -> some View {
SearchView()
}
}

View File

@ -0,0 +1,52 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import 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<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
LibraryCoordinator(viewModel: viewModel)
}
#if !os(tvOS)
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#endif
@ViewBuilder
func makeStart() -> some View {
SearchView()
}
}

View File

@ -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<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator())
}
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ConnectToServerView()
}
}
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
.environment(\.isEditing, true)
#if os(iOS)
.navigationBarCloseButton {
self.popLast()
}
#endif
}
}
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
NavigationViewCoordinator(UserSignInCoordinator(server: server))
}
@ViewBuilder
func makeStart() -> some View {
SelectUserView()
}
}

View File

@ -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<AppSettingsCoordinator> {
NavigationViewCoordinator(AppSettingsCoordinator())
}
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ConnectToServerView()
}
}
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
.environment(\.isEditing, true)
#if os(iOS)
.navigationBarCloseButton {
self.popLast()
}
#endif
}
}
func makeUserSignIn(server: ServerState) -> NavigationViewCoordinator<UserSignInCoordinator> {
NavigationViewCoordinator(UserSignInCoordinator(server: server))
}
@ViewBuilder
func makeStart() -> some View {
SelectUserView()
}
}

View File

@ -22,6 +22,10 @@ final class SelectUserCoordinator: NavigationCoordinatable {
@Route(.push) @Route(.push)
var connectToServer = makeConnectToServer var connectToServer = makeConnectToServer
@Route(.push) @Route(.push)
var connectToXtream = makeConnectToXtream
@Route(.push)
var dualServerConnect = makeDualServerConnect
@Route(.push)
var editServer = makeEditServer var editServer = makeEditServer
@Route(.push) @Route(.push)
var userSignIn = makeUserSignIn var userSignIn = makeUserSignIn
@ -30,10 +34,19 @@ final class SelectUserCoordinator: NavigationCoordinatable {
NavigationViewCoordinator(AppSettingsCoordinator()) NavigationViewCoordinator(AppSettingsCoordinator())
} }
func makeConnectToServer() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { @ViewBuilder
NavigationViewCoordinator { func makeConnectToServer() -> some View {
ConnectToServerView() ConnectToServerView()
} }
@ViewBuilder
func makeConnectToXtream() -> some View {
ConnectToXtreamView()
}
@ViewBuilder
func makeDualServerConnect() -> some View {
DualServerConnectView()
} }
func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> { func makeEditServer(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {

View File

@ -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<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
{
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView(user: user)
}
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
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<BasicNavigationViewCoordinator> {
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<UserProfileSettingsCoordinator> {
NavigationViewCoordinator(
UserProfileSettingsCoordinator(viewModel: viewModel)
)
}
// MARK: - Customize Settings View
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
NavigationViewCoordinator(
CustomizeSettingsCoordinator()
)
}
// MARK: - Experimental Settings View
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
ExperimentalSettingsView()
}
)
}
// MARK: - Poster Indicator Settings View
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
// MARK: - Server Settings View
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
}
}
// MARK: - Video Player Settings View
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
NavigationViewCoordinator(
VideoPlayerSettingsCoordinator()
)
}
// MARK: - Playback Settings View
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
SettingsView()
}
}

View File

@ -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<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
{
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeQuickConnectAuthorize(user: UserDto) -> some View {
QuickConnectAuthorizeView(user: user)
}
func makeResetUserPassword(userID: String) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ResetUserPasswordView(userID: userID, requiresCurrentPassword: true)
}
}
@ViewBuilder
func makeLocalSecurity() -> some View {
UserLocalSecurityView()
}
func makePhotoPicker(viewModel: UserProfileImageViewModel) -> NavigationViewCoordinator<UserProfileImageCoordinator> {
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<BasicNavigationViewCoordinator> {
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<UserProfileSettingsCoordinator> {
NavigationViewCoordinator(
UserProfileSettingsCoordinator(viewModel: viewModel)
)
}
// MARK: - Customize Settings View
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
NavigationViewCoordinator(
CustomizeSettingsCoordinator()
)
}
// MARK: - Experimental Settings View
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
ExperimentalSettingsView()
}
)
}
// MARK: - Poster Indicator Settings View
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
// MARK: - Server Settings View
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
}
}
// MARK: - Video Player Settings View
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
NavigationViewCoordinator(
VideoPlayerSettingsCoordinator()
)
}
// MARK: - Playback Settings View
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
#endif
@ViewBuilder
func makeLog() -> some View {
ConsoleView()
}
@ViewBuilder
func makeStart() -> some View {
SettingsView()
}
}

View File

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

View File

@ -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<TabItem> tabs: () -> [TabItem]) {
let tabs = tabs()
self.tabs = tabs.map { tab in
let coordinator = NavigationCoordinator()
let event = TabItemSelectedPublisher()
return (tab, coordinator, event)
}
}
}

View File

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

View File

@ -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<TabCoordinator.SelectedEvent>
}
@propertyWrapper
struct TabItemSelected: DynamicProperty {
@Environment(\.tabItemSelected)
private var publisher
var wrappedValue: TabCoordinator.TabItemSelectedPublisher {
publisher
}
}
extension EnvironmentValues {
@Entry
var tabItemSelected: TabCoordinator.TabItemSelectedPublisher = .init()
}

View File

@ -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<String>
let accessPolicy: Binding<UserAccessPolicy>
}
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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
accessPolicy: parameters.accessPolicy
)
}
}
#endif
@ViewBuilder
func makeStart() -> some View {
UserSignInView(server: server)
}
}

View File

@ -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<String>
let accessPolicy: Binding<UserAccessPolicy>
}
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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
QuickConnectView(quickConnect: quickConnect)
}
}
#if os(iOS)
func makeSecurity(parameters: SecurityParameters) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
UserSignInView.SecurityView(
pinHint: parameters.pinHint,
accessPolicy: parameters.accessPolicy
)
}
}
#endif
@ViewBuilder
func makeStart() -> some View {
UserSignInView(server: server)
}
}

View File

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

View File

@ -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<Value>: DynamicProperty {
@StateObject
var storage: PublishedBox<Value>
init(wrappedValue: Value) {
self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue))
}
var wrappedValue: Value {
get { storage.value }
nonmutating set { storage.value = newValue }
}
var projectedValue: Published<Value>.Publisher {
storage.$value
}
var box: PublishedBox<Value> {
storage
}
}

View File

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

View File

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

View File

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

View File

@ -105,6 +105,13 @@ extension BaseItemDto {
} }
logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned") 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( return try matchingMediaSource.liveVideoPlayerViewModel(
with: self, with: self,
playSessionID: response.value.playSessionID! playSessionID: response.value.playSessionID!

View File

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

View File

@ -0,0 +1,36 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import 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
)
}
}

View File

@ -72,34 +72,91 @@ extension MediaSourceInfo {
let playbackURL: URL let playbackURL: URL
let playMethod: PlayMethod let playMethod: PlayMethod
if let transcodingURL { print("🎬 liveVideoPlayerViewModel: Starting for item \(item.displayTitle)")
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL) print("🎬 Server URL: \(userSession.server.currentURL)")
else { throw JellyfinAPIError("Unable to construct transcoded url") } 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 playbackURL = fullTranscodeURL
playMethod = .transcode 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 playbackURL = playbackUrl
playMethod = .directPlay playMethod = .directPlay
print("🎬 Using direct play URL (relative): \(playbackURL)")
print("🎬 Absolute URL: \(playbackURL.absoluteString)")
} else { } else {
let videoStreamParameters = Paths.GetVideoStreamParameters( // Use Jellyfin's live.m3u8 endpoint for Live TV (same as web browser)
isStatic: true, // Construct URL: /videos/{id}/live.m3u8?DeviceId=...&MediaSourceId=...&PlaySessionId=...&api_key=...
tag: item.etag, let deviceId = userSession.client.configuration.deviceID ?? "unknown"
playSessionID: playSessionID, let apiKey = userSession.client.accessToken ?? ""
mediaSourceID: id
)
let videoStreamRequest = Paths.getVideoStream( var urlComponents = URLComponents()
itemID: item.id!, urlComponents.scheme = userSession.server.currentURL.scheme
parameters: videoStreamParameters 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 { guard let liveURL = urlComponents.url else {
throw JellyfinAPIError("Unable to construct transcoded url") print("🎬 ERROR: Unable to construct live.m3u8 URL")
throw JellyfinAPIError("Unable to construct live.m3u8 URL")
} }
playbackURL = fullURL playbackURL = liveURL
playMethod = .directPlay 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 videoStreams = mediaStreams?.filter { $0.type == .video } ?? []
let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? [] let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? [] let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? []

View File

@ -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<AnyShapeStyle> {
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(
imageStyle: ImageStyle
) -> SectionFooterWithImageLabelStyle<ImageStyle> {
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
}
}
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
let imageStyle: ImageStyle
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon
.foregroundStyle(imageStyle)
.fontWeight(.bold)
configuration.title
}
}
}

View File

@ -23,7 +23,7 @@ extension DataCache.Swiftfin {
static let posters: DataCache? = { 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 } guard let url = name.url else { return nil }
return ImagePipeline.cacheKey(for: url) return ImagePipeline.cacheKey(for: url)
} }
@ -40,7 +40,7 @@ extension DataCache.Swiftfin {
return nil 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 let dataCache = try? DataCache(path: path) { name in

View File

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

View File

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

View File

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

View File

@ -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<Value>: ObservableObject {
@Published
var value: Value
init(initialValue: Value) {
self.value = initialValue
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "<Redacted by Swiftfin>"
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)
}
}

View File

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

View File

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

View File

@ -53,4 +53,20 @@ extension ChannelProgram: Poster {
var systemImage: String { var systemImage: String {
channel.systemImage 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)
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Value>: @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<Value> {
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()))
}
}

View File

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

View File

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

View File

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

View File

@ -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<Bool> = .init(initialValue: false)
var isScrubbing: Binding<Bool> = .constant(false)
var scrubbedSeconds: Binding<Duration> = .constant(.zero)
var videoSize: PublishedBox<CGSize> = .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<Duration>
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
}
}
}

View File

@ -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<Bool> = .init(initialValue: false)
let videoSize: PublishedBox<CGSize> = .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)
}
}
}
}
}
}

View File

@ -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<Bool> { 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<CGSize> { 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)
}

View File

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

View File

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

View File

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

View File

@ -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<UIImage?, Never>] = [:]
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<UIImage?, Never> {
let client = Container.shared.currentUserSession()!.client
guard let chapterInfo = chapters[safe: chapterIndex], let imageUrl = chapterInfo.imageSource.url else { return nil }
let request: Request<Data> = .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
}
}

View File

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

View File

@ -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<Duration>
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<TrickplayImage?, Never>] = [:]
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<TrickplayImage?, Never> {
Task<TrickplayImage?, Never> { [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
}
}
}

View File

@ -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<Bool>.Publisher = $hasNextItem
lazy var hasPreviousItemPublisher: Published<Bool>.Publisher = $hasPreviousItem
lazy var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $nextItem
lazy var previousItemPublisher: Published<MediaPlayerItemProvider?>.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<SeasonItemViewModel.ID?>
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<SeasonItemViewModel.ID?>,
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<SeasonItemViewModel.ID?>
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<SeasonItemViewModel.ID?>,
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<SeasonItemViewModel.ID?>
let selectionViewModel: SeasonItemViewModel
init(
selection: Binding<SeasonItemViewModel.ID?>,
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)
}
}
}

View File

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

View File

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

View File

@ -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<Bool>.Publisher { get set }
var hasPreviousItemPublisher: Published<Bool>.Publisher { get set }
var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher { get set }
var previousItemPublisher: Published<MediaPlayerItemProvider?>.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<Bool>.Publisher = $hasNextItem
lazy var hasPreviousItemPublisher: Published<Bool>.Publisher = $hasPreviousItem
lazy var nextItemPublisher: Published<MediaPlayerItemProvider?>.Publisher = $nextItem
lazy var previousItemPublisher: Published<MediaPlayerItemProvider?>.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()
}
}

View File

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

View File

@ -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<Value>: ObservableObject {
@Published
private(set) var wrappedValue: Value
var projectedValue: AnyPublisher<Value, Never> {
$wrappedValue
.eraseToAnyPublisher()
}
private var cancellables = Set<AnyCancellable>()
init<P: Publisher>(
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<T: ObservableObject>(
_enclosingInstance instance: T,
wrapped wrappedKeyPath: KeyPath<T, Value>,
storage storageKeyPath: KeyPath<T, ObservedPublisher<Value>>
) -> 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
}
}

Some files were not shown because too many files have changed in this diff Show More