Implement dual provider architecture (Jellyfin + Xtream)

Add support for running both Jellyfin and Xtream providers simultaneously:
- User can have 1 active JF account + 1 active XC account at same time
- Tabs dynamically show/hide based on active providers
- Account switcher allows switching between multiple saved accounts

New Components:
- XtreamSession: Session management for Xtream providers
- SessionManager: Tracks both JF and XC sessions
- XtreamView/ViewModel: New Xtream tab with Live TV categories
- TabCoordinator: Dynamic tab building based on active providers

Modified Components:
- MainTabView: Injects SessionManager, rebuilds tabs on provider changes
- TabItem: Added Xtream tab definition
- MediaViewModel: Excludes Xtream channels (now in Xtream tab only)

Scripts:
- Added .claude/scripts/create-issue.sh for Gitea issue creation

Closes: jellyflood-2 (partial)
Related: jellyflood-1, jellyflood-5

🤖 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 11:47:41 +02:00
parent 1f5b1a52ff
commit ed91d39ad9
9 changed files with 476 additions and 32 deletions

60
.claude/scripts/create-issue.sh Executable file
View File

@ -0,0 +1,60 @@
#!/bin/bash
#
# Create a Gitea issue for jellyflood
# Usage: ./create-issue.sh "Issue Title" "Issue Body" "feature|bug"
#
set -e
# Load environment variables
source ~/.zshrc
# Gitea configuration
GITEA_URL="https://git.ashik.se"
REPO_OWNER="ashikk"
REPO_NAME="jellyflood"
# Check for API token in environment
if [ -z "$GITEA_API_TOKEN" ]; then
echo "Error: GITEA_API_TOKEN environment variable not set"
echo "Please set it in your ~/.zshrc"
echo "Get a token at: $GITEA_URL/user/settings/applications"
exit 1
fi
# Check arguments
if [ $# -lt 2 ]; then
echo "Usage: $0 \"Issue Title\" \"Issue Body\" [feature|bug]"
echo ""
echo "Example:"
echo " $0 \"jellyflood-8: New Feature\" \"Description here\" \"feature\""
exit 1
fi
TITLE="$1"
BODY="$2"
LABEL="${3:-feature}"
# Add label to body
FULL_BODY="**Label:** $LABEL
$BODY"
# Create issue
echo "Creating issue: $TITLE"
RESPONSE=$(curl -s -X POST \
-H "Authorization: token $GITEA_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$TITLE" --arg body "$FULL_BODY" '{title: $title, body: $body}')" \
"$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/issues")
ISSUE_NUMBER=$(echo "$RESPONSE" | jq -r '.number // "error"')
if [ "$ISSUE_NUMBER" = "error" ]; then
echo "Error creating issue:"
echo "$RESPONSE" | jq -r '.message // .'
exit 1
fi
echo "✅ Issue #$ISSUE_NUMBER created successfully!"
echo "View at: $GITEA_URL/$REPO_OWNER/$REPO_NAME/issues/$ISSUE_NUMBER"

View File

@ -15,32 +15,16 @@ import SwiftUI
// TODO: fix weird tvOS icon rendering
struct MainTabView: View {
#if os(iOS)
@Injected(\.sessionManager)
private var sessionManager
@StateObject
private var tabCoordinator = TabCoordinator {
TabItem.home
TabItem.search
TabItem.media
private var tabCoordinator: TabCoordinator
init() {
let sessionManager = Container.shared.sessionManager()
_tabCoordinator = StateObject(wrappedValue: TabCoordinator(sessionManager: sessionManager))
}
#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 {
@ -65,5 +49,11 @@ struct MainTabView: View {
.tag(tab.item.id)
}
}
.onReceive(sessionManager.$jellyfinSession) { _ in
tabCoordinator.refreshTabs()
}
.onReceive(sessionManager.$xtreamSession) { _ in
tabCoordinator.refreshTabs()
}
}
}

View File

@ -39,12 +39,73 @@ final class TabCoordinator: ObservableObject {
@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)
private let sessionManager: SessionManager
init(sessionManager: SessionManager) {
self.sessionManager = sessionManager
refreshTabs()
}
func refreshTabs() {
let newTabs = buildTabs()
// Preserve navigation coordinators for existing tabs
let preservedTabs = newTabs.map { newTab in
if let existingTab = tabs.first(where: { $0.item.id == newTab.id }) {
return (newTab, existingTab.coordinator, existingTab.publisher)
} else {
let coordinator = NavigationCoordinator()
let publisher = TabItemSelectedPublisher()
return (newTab, coordinator, publisher)
}
}
tabs = preservedTabs
// Reset selected tab if it no longer exists
if let selectedID = selectedTabID, !tabs.contains(where: { $0.item.id == selectedID }) {
selectedTabID = tabs.first?.item.id
}
}
private func buildTabs() -> [TabItem] {
var tabItems: [TabItem] = []
// Add Jellyfin tabs if JF session is active
if sessionManager.hasJellyfinProvider {
#if os(iOS)
tabItems.append(TabItem.home)
tabItems.append(TabItem.search)
tabItems.append(TabItem.media)
#else
tabItems.append(TabItem.home)
tabItems.append(TabItem.library(
title: L10n.tvShows,
systemName: "tv",
filters: .init(itemTypes: [.series])
))
tabItems.append(TabItem.library(
title: L10n.movies,
systemName: "film",
filters: .init(itemTypes: [.movie])
))
tabItems.append(TabItem.search)
tabItems.append(TabItem.media)
#endif
}
// Add Xtream tab if XC session is active
if sessionManager.hasXtreamProvider {
tabItems.append(TabItem.xtream)
}
// Always add settings on tvOS (if JF is active)
#if !os(iOS)
if sessionManager.hasJellyfinProvider {
tabItems.append(TabItem.settings)
}
#endif
return tabItems
}
}

View File

@ -76,6 +76,14 @@ extension TabItem {
MediaView()
}
static let xtream = TabItem(
id: "xtream",
title: "Xtream",
systemImage: "antenna.radiowaves.left.and.right"
) {
XtreamView()
}
static let search = TabItem(
id: "search",
title: L10n.search,

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 Combine
import Defaults
import Factory
import Foundation
/// Manages active provider sessions (Jellyfin and/or Xtream)
final class SessionManager: ObservableObject {
@Published
var jellyfinSession: UserSession?
@Published
var xtreamSession: XtreamSession?
private var cancellables = Set<AnyCancellable>()
init() {
// Initialize with current sessions
self.jellyfinSession = Container.shared.currentUserSession()
self.xtreamSession = Container.shared.currentXtreamSession()
// Listen for session changes
NotificationCenter.default.publisher(for: .didSignIn)
.sink { [weak self] _ in
self?.refreshJellyfinSession()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .didSignOut)
.sink { [weak self] _ in
self?.refreshJellyfinSession()
}
.store(in: &cancellables)
// Listen for Xtream session changes
Defaults.publisher(.currentXtreamServerID)
.sink { [weak self] _ in
self?.refreshXtreamSession()
}
.store(in: &cancellables)
}
var hasActiveProvider: Bool {
jellyfinSession != nil || xtreamSession != nil
}
var hasJellyfinProvider: Bool {
jellyfinSession != nil
}
var hasXtreamProvider: Bool {
xtreamSession != nil
}
private func refreshJellyfinSession() {
jellyfinSession = Container.shared.currentUserSession()
}
private func refreshXtreamSession() {
xtreamSession = Container.shared.currentXtreamSession()
}
func signOutJellyfin() {
NotificationCenter.default.post(name: .didSignOut, object: nil)
}
func signOutXtream() {
Defaults[.currentXtreamServerID] = nil
}
}
extension Container {
var sessionManager: Factory<SessionManager> {
self {
SessionManager()
}.singleton
}
}

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 Defaults
import Factory
import Foundation
/// Session for an active Xtream Codes provider
final class XtreamSession {
let server: XtreamServer
let client: XtreamAPIClient
init(server: XtreamServer) {
self.server = server
self.client = XtreamAPIClient(server: server)
}
var id: String {
server.id
}
var name: String {
server.name
}
}
extension Container {
/// Current active Xtream session (if any)
var currentXtreamSession: Factory<XtreamSession?> {
self {
guard let currentServerID = Defaults[.currentXtreamServerID] else { return nil }
let servers = Defaults[.xtreamServers]
guard let server = servers.first(where: { $0.id == currentServerID }) else {
// Had last server ID but no saved server
Defaults[.currentXtreamServerID] = nil
return nil
}
return XtreamSession(server: server)
}.cached
}
}

View File

@ -97,9 +97,9 @@ final class MediaViewModel: ViewModel, Stateful {
// force it to `folders` for better view handling
let supportedUserViews = try await (userViews.value.items ?? [])
.filter { item in
// Include channels (which don't have collectionType)
// EXCLUDE channels - they are now in the Xtream tab
if item.type == .channel {
return true
return false
}
// Include items with supported collectionTypes
if let collectionType = item.collectionType {

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 Defaults
import Factory
import Foundation
import JellyfinAPI
import OrderedCollections
final class XtreamViewModel: ViewModel, Stateful {
@Injected(\.sessionManager)
private var sessionManager
var xtreamSession: XtreamSession? {
sessionManager.xtreamSession
}
// MARK: Action
enum Action: Equatable {
case error(JellyfinAPIError)
case refresh
}
// MARK: State
enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
}
@Published
var xtreamChannels: OrderedSet<BaseItemDto> = []
@Published
var backgroundStates: Set<BackgroundState> = []
@Published
var state: State = .initial
func respond(to action: Action) -> State {
switch action {
case let .error(error):
return .error(error)
case .refresh:
cancellables.removeAll()
Task {
do {
try await refresh()
await MainActor.run {
self.state = .content
}
} catch {
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}
}
.store(in: &cancellables)
return .refreshing
}
}
private func refresh() async throws {
guard let session = xtreamSession else {
throw XtreamAPIError.invalidResponse
}
await MainActor.run {
xtreamChannels.removeAll()
}
let categories = try await session.client.getLiveCategories()
// Convert Xtream categories to BaseItemDto for now
// TODO: Create proper Xtream data models
let items = categories.map { category -> BaseItemDto in
var item = BaseItemDto()
item.id = category.categoryId
item.name = category.categoryName
item.type = .channel
return item
}
await MainActor.run {
xtreamChannels.elements = items
}
}
func randomItemImageSources(for channel: BaseItemDto) async throws -> [ImageSource] {
// TODO: Implement Xtream-specific image fetching
[]
}
}

View File

@ -0,0 +1,82 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import CollectionVGrid
import Defaults
import Factory
import JellyfinAPI
import SwiftUI
struct XtreamView: View {
@Router
private var router
@StateObject
private var viewModel = XtreamViewModel()
private var layout: CollectionVGridLayout {
if UIDevice.isTV {
.columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50)
} else if UIDevice.isPad {
.minWidth(200)
} else {
.columns(2)
}
}
@ViewBuilder
private var content: some View {
CollectionVGrid(
uniqueElements: viewModel.xtreamChannels,
layout: layout
) { channel in
MediaItem(viewModel: viewModel, type: .collectionFolder(channel)) { namespace in
let channelViewModel = ItemLibraryViewModel(
parent: channel,
filters: .default
)
router.route(to: .library(viewModel: channelViewModel), in: namespace)
}
}
}
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
var body: some View {
ZStack {
Color.clear
switch viewModel.state {
case .content:
content
case let .error(error):
errorView(with: error)
case .initial:
content
case .refreshing:
ProgressView()
}
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea()
.navigationTitle("Xtream")
.onFirstAppear {
viewModel.send(.refresh)
}
.if(UIDevice.isTV) { view in
view.toolbar(.hidden, for: .navigationBar)
}
}
}