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:
parent
1f5b1a52ff
commit
ed91d39ad9
|
@ -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"
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
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 event = TabItemSelectedPublisher()
|
||||
return (tab, coordinator, event)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
[]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue