mirror of
https://github.com/dmunozv04/iSponsorBlockTV.git
synced 2025-12-08 21:06:43 +03:00
701 lines
31 KiB
Python
701 lines
31 KiB
Python
import aiohttp
|
|
import asyncio
|
|
import copy
|
|
# Textual imports (Textual is awesome!)
|
|
from textual import on
|
|
from textual.app import App, ComposeResult
|
|
from textual.containers import ScrollableContainer, Grid, Container, Vertical, Horizontal
|
|
from textual.events import Click
|
|
from textual.screen import Screen
|
|
from textual.validation import Function
|
|
from textual.widgets import Button, Footer, Header, Static, Label, Input, SelectionList, Checkbox, ContentSwitcher, \
|
|
RadioSet, RadioButton
|
|
from textual.widgets.selection_list import Selection
|
|
from textual_slider import Slider
|
|
# Local imports
|
|
from . import api_helpers, ytlounge
|
|
from .constants import skip_categories
|
|
|
|
|
|
def _validate_pairing_code(pairing_code: str) -> bool:
|
|
try:
|
|
pairing_code = pairing_code.replace("-", "").replace(" ", "")
|
|
int(pairing_code)
|
|
return len(pairing_code) == 12
|
|
except ValueError:
|
|
return False # not a number
|
|
|
|
|
|
class ModalWithClickExit(Screen):
|
|
"""A modal screen that exits when clicked outside its bounds.
|
|
https://discord.com/channels/1026214085173461072/1033754296224841768/1136015817356611604"""
|
|
DEFAULT_CSS = """
|
|
ModalWithClickExit {
|
|
align: center middle;
|
|
layout: vertical;
|
|
overflow-y: auto;
|
|
background: $background 60%;
|
|
}
|
|
"""
|
|
|
|
@on(Click)
|
|
def close_out_bounds(self, event: Click) -> None:
|
|
if self.get_widget_at(event.screen_x, event.screen_y)[0] is self:
|
|
self.dismiss()
|
|
|
|
|
|
class Element(Static):
|
|
"""Base class for elements (devices and channels).
|
|
It has a name and a remove button.
|
|
"""
|
|
|
|
def __init__(self, element: dict, tooltip: str = None, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.element_data = element
|
|
self.element_name = ""
|
|
self.process_values_from_data()
|
|
self.tooltip = tooltip
|
|
|
|
def process_values_from_data(self):
|
|
pass
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Button(label=self.element_name, classes="element-name", disabled=True, id="element-name")
|
|
yield Button("Remove", classes="element-remove", variant="error", id="element-remove")
|
|
|
|
def on_mount(self) -> None:
|
|
if self.tooltip:
|
|
self.query_one(".element-name").tooltip = self.tooltip
|
|
self.query_one(".element-name").disabled = False
|
|
|
|
|
|
class Device(Element):
|
|
"""A device element."""
|
|
|
|
def process_values_from_data(self):
|
|
print("HIIII")
|
|
print(self.element_data)
|
|
if "name" in self.element_data and self.element_data["name"]:
|
|
self.element_name = self.element_data["name"]
|
|
else:
|
|
self.element_name = f"Unnamed device with id {self.element_data['screen_id'][:5]}...{self.element_data['screen_id'][-5:]}"
|
|
|
|
|
|
class Channel(Element):
|
|
"""A channel element."""
|
|
|
|
def process_values_from_data(self):
|
|
if "name" in self.element_data:
|
|
self.element_name = self.element_data["name"]
|
|
else:
|
|
self.element_name = f"Unnamed channel with id {self.element_data['channel_id']}"
|
|
|
|
|
|
class ChannelRadio(RadioButton):
|
|
"""A radio button for a channel."""
|
|
|
|
def __init__(self, channel: tuple, **kwargs) -> None:
|
|
label = f"{channel[1]} - Subs: {channel[2]}"
|
|
super().__init__(label=label, **kwargs)
|
|
self.channel_data = channel
|
|
|
|
|
|
class MigrationScreen(ModalWithClickExit):
|
|
"""Screen with a dialog to remove old ATVS config."""
|
|
BINDINGS = [("escape", "dismiss()", "Cancel"),
|
|
("s", "remove_and_save", "Remove and save"),
|
|
("q,ctrl+c", "exit", "Exit")]
|
|
AUTO_FOCUS = "#exit-save"
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Grid(
|
|
Label(
|
|
"Welcome to the new configurator! You seem to have the legacy 'atvs' entry on your config file, do you want to remove it?\n(The app won't start with it present)",
|
|
id="question", classes="button-100"),
|
|
Button("Remove and save", variant="primary", id="migrate-remove-save", classes="button-100"),
|
|
Button("Don't remove", variant="error", id="migrate-no-change", classes="button-100"),
|
|
id="dialog-migration",
|
|
)
|
|
|
|
def action_exit(self) -> None:
|
|
self.app.exit()
|
|
|
|
@on(Button.Pressed, "#migrate-no-change")
|
|
def action_no_change(self) -> None:
|
|
self.app.pop_screen()
|
|
|
|
@on(Button.Pressed, "#migrate-remove-save")
|
|
def action_remove_and_save(self) -> None:
|
|
del self.app.config.atvs
|
|
self.app.config.save()
|
|
self.app.pop_screen()
|
|
|
|
|
|
class ExitScreen(ModalWithClickExit):
|
|
"""Screen with a dialog to exit."""
|
|
BINDINGS = [("escape", "dismiss()", "Cancel"),
|
|
("s", "save", "Save"),
|
|
("q,ctrl+c", "exit", "Exit")]
|
|
AUTO_FOCUS = "#exit-save"
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Grid(
|
|
Label("Are you sure you want to exit without saving?", id="question", classes="button-100"),
|
|
Button("Save", variant="success", id="exit-save", classes="button-100"),
|
|
Button("Don't save", variant="error", id="exit-no-save", classes="button-100"),
|
|
Button("Cancel", variant="primary", id="exit-cancel", classes="button-100"),
|
|
id="dialog-exit",
|
|
)
|
|
|
|
def action_exit(self) -> None:
|
|
self.app.exit()
|
|
|
|
def action_save(self) -> None:
|
|
self.app.config.save()
|
|
self.app.exit()
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "exit-no-save":
|
|
self.app.exit()
|
|
elif event.button.id == "exit-save":
|
|
self.app.config.save()
|
|
self.app.exit()
|
|
else:
|
|
self.app.pop_screen()
|
|
|
|
|
|
class AddDevice(ModalWithClickExit):
|
|
"""Screen with a dialog to add a device, either with a pairing code or with lan discovery."""
|
|
BINDINGS = [("escape", "dismiss({})", "Return")]
|
|
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
web_session = aiohttp.ClientSession()
|
|
self.api_helper = api_helpers.ApiHelper(config, web_session)
|
|
self.devices_discovered_dial = []
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Container(id="add-device-container"):
|
|
yield Label("Add Device", classes="title")
|
|
with Grid(id="add-device-switch-buttons"):
|
|
yield Button("Add with pairing code", id="add-device-pin-button", classes="button-switcher")
|
|
yield Button("Add with lan discovery", id="add-device-dial-button", classes="button-switcher")
|
|
with ContentSwitcher(id="add-device-switcher", initial="add-device-pin-container"):
|
|
with Container(id="add-device-pin-container"):
|
|
yield Input(placeholder="Pairing Code (found in Settings - Link with TV code)",
|
|
id="pairing-code-input",
|
|
validators=[
|
|
Function(_validate_pairing_code, "Invalid pairing code format")
|
|
]
|
|
)
|
|
yield Input(placeholder="Device Name (auto filled if empty/optional)", id="device-name-input")
|
|
yield Button("Add", id="add-device-pin-add-button", variant="success", disabled=True)
|
|
yield Label(id="add-device-info")
|
|
with Container(id="add-device-dial-container"):
|
|
yield Label(
|
|
"Make sure your device is on the same network as this computer\nIf it isn't showing up, try restarting the app.\nIf running in docker, make sure to use `--network=host`\nTo refresh the list, close and open the dialog again",
|
|
classes="subtitle")
|
|
yield SelectionList(("Searching for devices...", "", False), id="dial-devices-list", disabled=True)
|
|
yield Button("Add selected devices", id="add-device-dial-add-button", variant="success",
|
|
disabled=True)
|
|
|
|
async def on_mount(self) -> None:
|
|
self.devices_discovered_dial = []
|
|
asyncio.create_task(self.task_discover_devices())
|
|
|
|
async def task_discover_devices(self):
|
|
devices_found = await self.api_helper.discover_youtube_devices_dial()
|
|
list_widget: SelectionList = self.query_one("#dial-devices-list")
|
|
list_widget.clear_options()
|
|
if devices_found:
|
|
# print(devices_found)
|
|
devices_found_parsed = []
|
|
for index, i in enumerate(devices_found):
|
|
devices_found_parsed.append(Selection(i["name"], index, False))
|
|
list_widget.add_options(devices_found_parsed)
|
|
self.query_one("#dial-devices-list").disabled = False
|
|
self.devices_discovered_dial = devices_found
|
|
else:
|
|
list_widget.add_option(("No devices found", "", False))
|
|
|
|
@on(Button.Pressed, "#add-device-switch-buttons > *")
|
|
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
|
button_ = event.button.id
|
|
self.query_one("#add-device-switcher").current = event.button.id.replace("-button", "-container")
|
|
|
|
@on(Input.Changed, "#pairing-code-input")
|
|
def changed_pairing_code(self, event: Input.Changed):
|
|
self.query_one("#add-device-button").disabled = not event.validation_result.is_valid
|
|
|
|
@on(Input.Submitted, "#pairing-code-input")
|
|
@on(Button.Pressed, "#add-device-pin-add-button")
|
|
async def handle_add_device_pin(self) -> None:
|
|
self.query_one("#add-device-button").disabled = True
|
|
lounge_controller = ytlounge.YtLoungeApi("iSponsorBlockTV")
|
|
pairing_code = self.query_one("#pairing-code-input").value
|
|
pairing_code = int(pairing_code.replace("-", "").replace(" ", "")) # remove dashes and spaces
|
|
device_name = self.parent.query_one("#device-name-input").value
|
|
paired = False
|
|
try:
|
|
paired = await lounge_controller.pair(pairing_code)
|
|
except:
|
|
pass
|
|
if paired:
|
|
device = {
|
|
"screen_id": lounge_controller.auth.screen_id,
|
|
"name": device_name if device_name else lounge_controller.screen_name,
|
|
"offset": 0,
|
|
}
|
|
self.query_one("#pairing-code-input").value = ""
|
|
self.query_one("#device-name-input").value = ""
|
|
self.query_one("#add-device-info").update(f"[#00ff00][b]Successfully added {device['name']}")
|
|
self.dismiss([device])
|
|
else:
|
|
self.query_one("#pairing-code-input").value = ""
|
|
self.query_one("#add-device-button").disabled = False
|
|
self.query_one("#add-device-info").update("[#ff0000]Failed to add device")
|
|
|
|
@on(Button.Pressed, "#add-device-dial-add-button")
|
|
def handle_add_device_dial(self) -> None:
|
|
list_widget: SelectionList = self.query_one("#dial-devices-list")
|
|
selected_devices = list_widget.selected
|
|
devices = []
|
|
for i in selected_devices:
|
|
devices.append(self.devices_discovered_dial[i])
|
|
self.dismiss(devices)
|
|
|
|
@on(SelectionList.SelectedChanged, "#dial-devices-list")
|
|
def changed_device_list(self, event: SelectionList.SelectedChanged):
|
|
self.query_one("#add-device-dial-add-button").disabled = not event.selection_list.selected
|
|
|
|
|
|
class AddChannel(ModalWithClickExit):
|
|
"""Screen with a dialog to add a channel, either using search or with a channel id."""
|
|
BINDINGS = [("escape", "dismiss(())", "Return")]
|
|
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
web_session = aiohttp.ClientSession()
|
|
self.api_helper = api_helpers.ApiHelper(config, web_session)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Container(id="add-channel-container"):
|
|
yield Label("Add Channel", classes="title")
|
|
yield Label(
|
|
"Select a method to add a channel. Adding via search only works if a YouTube api key has been set",
|
|
id="add-channel-label", classes="subtitle")
|
|
with Grid(id="add-channel-switch-buttons"):
|
|
yield Button("Add by channel name", id="add-channel-search-button", classes="button-switcher")
|
|
yield Button("Add by channel ID", id="add-channel-id-button", classes="button-switcher")
|
|
yield Label(id="add-channel-info", classes="subtitle")
|
|
with ContentSwitcher(id="add-channel-switcher", initial="add-channel-search-container"):
|
|
with Vertical(id="add-channel-search-container"):
|
|
if self.config.apikey:
|
|
with Grid(id="add-channel-search-inputs"):
|
|
yield Input(placeholder="Enter channel name", id="channel-name-input-search")
|
|
yield Button("Search", id="search-channel-button", variant="success")
|
|
yield RadioSet(RadioButton(label="Search to see results", disabled=True),
|
|
id="channel-search-results")
|
|
yield Button("Add", id="add-channel-button-search", variant="success", disabled=True,
|
|
classes="button-100")
|
|
else:
|
|
yield Label(
|
|
"[#ff0000]No api key set, cannot search for channels. You can add it the config section below",
|
|
id="add-channel-search-no-key", classes="subtitle")
|
|
with Vertical(id="add-channel-id-container"):
|
|
yield Input(placeholder="Enter channel ID (example: UCuAXFkgsw1L7xaCfnd5JJOw)",
|
|
id="channel-id-input")
|
|
yield Input(placeholder="Enter channel name (only used to display in the config file)",
|
|
id="channel-name-input-id")
|
|
yield Button("Add", id="add-channel-button-id", variant="success", classes="button-100")
|
|
|
|
@on(RadioSet.Changed, "#channel-search-results")
|
|
def handle_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
|
self.query_one("#add-channel-button-search").disabled = False
|
|
|
|
@on(Button.Pressed, "#add-channel-switch-buttons > *")
|
|
def handle_switch_buttons(self, event: Button.Pressed) -> None:
|
|
button_ = event.button.id
|
|
self.query_one("#add-channel-switcher").current = event.button.id.replace("-button", "-container")
|
|
|
|
@on(Button.Pressed, "#search-channel-button")
|
|
@on(Input.Submitted, "#channel-name-input-search")
|
|
async def handle_search_channel(self) -> None:
|
|
channel_name = self.query_one("#channel-name-input-search").value
|
|
if not channel_name:
|
|
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel name")
|
|
return
|
|
self.query_one("#search-channel-button").disabled = True
|
|
self.query_one("#add-channel-info").update("Searching...")
|
|
self.query_one("#add-channel-button-search").disabled = True
|
|
self.query_one("#channel-search-results").remove_children()
|
|
try:
|
|
channels_list = await self.api_helper.search_channels(channel_name)
|
|
except:
|
|
self.query_one("#add-channel-info").update("[#ff0000]Failed to search for channel")
|
|
self.query_one("#search-channel-button").disabled = False
|
|
return
|
|
for i in channels_list:
|
|
self.query_one("#channel-search-results").mount(ChannelRadio(i))
|
|
if channels_list:
|
|
self.query_one("#search-channel-button").disabled = False
|
|
self.query_one("#add-channel-info").update("")
|
|
|
|
@on(Button.Pressed, "#add-channel-button-search")
|
|
def handle_add_channel_search(self) -> None:
|
|
channel = self.query_one("#channel-search-results").pressed_button.channel_data
|
|
if not channel:
|
|
self.query_one("#add-channel-info").update("[#ff0000]Please select a channel")
|
|
return
|
|
self.query_one("#add-channel-info").update("Adding...")
|
|
self.dismiss(channel)
|
|
|
|
@on(Button.Pressed, "#add-channel-button-id")
|
|
@on(Input.Submitted, "#channel-id-input")
|
|
@on(Input.Submitted, "#channel-name-input-id")
|
|
def handle_add_channel_id(self) -> None:
|
|
channel_id = self.query_one("#channel-id-input").value
|
|
channel_name = self.query_one("#channel-name-input-id").value
|
|
if not channel_id:
|
|
self.query_one("#add-channel-info").update("[#ff0000]Please enter a channel ID")
|
|
return
|
|
if not channel_name:
|
|
channel_name = channel_id
|
|
channel = (channel_id, channel_name, "hidden")
|
|
self.query_one("#add-channel-info").update("Adding...")
|
|
self.dismiss(channel)
|
|
|
|
|
|
class EditDevice(ModalWithClickExit):
|
|
"""Screen with a dialog to edit a device. Used by the DevicesManager."""
|
|
BINDINGS = [("escape", "close_screen_saving", "Return")]
|
|
|
|
def __init__(self, device: Element, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.device_data = device.element_data
|
|
self.device_widget = device
|
|
|
|
def action_close_screen_saving(self) -> None:
|
|
self.dismiss()
|
|
|
|
def dismiss(self) -> None:
|
|
self.device_data["name"] = self.query_one("#device-name-input").value
|
|
self.device_data["screen_id"] = self.query_one("#device-id-input").value
|
|
self.device_data["offset"] = int(self.query_one("#device-offset-input").value)
|
|
super().dismiss(self.device_widget)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
name = self.device_data.get("name", "")
|
|
offset = self.device_data.get("offset", 0)
|
|
with Container(id="edit-device-container"):
|
|
yield Label("Edit device (ESCAPE to exit)", classes="title")
|
|
yield Label("Device name")
|
|
yield Input(placeholder="Device name", id="device-name-input", value=name)
|
|
yield Label("Device screen id")
|
|
with Grid(id="device-id-container"):
|
|
yield Input(placeholder="Device id", id="device-id-input", value=self.device_data["screen_id"],
|
|
password=True)
|
|
yield Button("Show id", id="device-id-view")
|
|
yield Label("Device offset (in milliseconds)")
|
|
with Horizontal(id="device-offset-container"):
|
|
yield Input(id="device-offset-input", value=str(offset))
|
|
yield Slider(name="Device offset", id="device-offset-slider", min=0, max=2000, step=100, value=offset)
|
|
|
|
def on_slider_changed(self, event: Slider.Changed) -> None:
|
|
input = self.query_one("#device-offset-input")
|
|
with input.prevent(Input.Changed):
|
|
input.value = str(event.slider.value)
|
|
|
|
def on_input_changed(self, event: Input.Changed):
|
|
if event.input.id == "device-offset-input":
|
|
value = event.input.value
|
|
if value.isdigit():
|
|
value = int(value)
|
|
slider = self.query_one("#device-offset-slider")
|
|
with slider.prevent(Slider.Changed):
|
|
self.query_one("#device-offset-slider").value = value
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "device-id-view":
|
|
if "Show" in event.button.label:
|
|
event.button.label = "Hide id"
|
|
self.query_one("#device-id-input").password = False
|
|
else:
|
|
event.button.label = "Show id"
|
|
self.query_one("#device-id-input").password = True
|
|
|
|
|
|
class DevicesManager(Vertical):
|
|
"""Manager for devices, allows to add, edit and remove devices."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
self.devices = config.devices
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Devices", classes="title")
|
|
with Horizontal(id="add-device-button-container"):
|
|
yield Button("Add Device", id="add-device", classes="button-100")
|
|
for device in self.devices:
|
|
yield Device(device, tooltip="Click to edit")
|
|
|
|
def new_devices(self, device_data) -> None:
|
|
if device_data:
|
|
device_widget = None
|
|
for i in device_data:
|
|
self.devices.append(i)
|
|
device_widget = Device(i, tooltip="Click to edit")
|
|
self.mount(device_widget)
|
|
device_widget.focus(scroll_visible=True)
|
|
|
|
def edit_device(self, device_widget: Element) -> None:
|
|
device_widget.process_values_from_data()
|
|
device_widget.query_one("#element-name").label = device_widget.element_name
|
|
|
|
@on(Button.Pressed, "#element-remove")
|
|
def remove_channel(self, event: Button.Pressed):
|
|
channel_to_remove: Element = event.button.parent
|
|
self.config.channel_whitelist.remove(channel_to_remove.element_data)
|
|
channel_to_remove.remove()
|
|
|
|
@on(Button.Pressed, "#add-device")
|
|
def add_device(self, event: Button.Pressed):
|
|
self.app.push_screen(AddDevice(self.config), callback=self.new_devices)
|
|
|
|
@on(Button.Pressed, "#element-name")
|
|
def edit_channel(self, event: Button.Pressed):
|
|
channel_to_edit: Element = event.button.parent
|
|
self.app.push_screen(EditDevice(channel_to_edit), callback=self.edit_device)
|
|
|
|
|
|
class ApiKeyManager(Vertical):
|
|
"""Manager for the YouTube Api Key."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("YouTube Api Key", classes="title")
|
|
yield Label(
|
|
"You can get a YouTube Api Key from the [link=https://console.developers.google.com/apis/credentials]Google Cloud Console[/link]")
|
|
with Grid(id="api-key-grid"):
|
|
yield Input(placeholder="YouTube Api Key", id="api-key-input", password=True, value=self.config.apikey)
|
|
yield Button("Show key", id="api-key-view")
|
|
|
|
@on(Input.Changed, "#api-key-input")
|
|
def changed_api_key(self, event: Input.Changed):
|
|
self.config.apikey = event.input.value
|
|
# try: # ChannelWhitelist might not be mounted
|
|
# self.app.query_one("#warning-no-key").display = not self.config.apikey
|
|
# except:
|
|
# pass
|
|
|
|
@on(Button.Pressed, "#api-key-view")
|
|
def pressed_api_key_view(self, event: Button.Pressed):
|
|
if "Show" in event.button.label:
|
|
event.button.label = "Hide key"
|
|
self.query_one("#api-key-input").password = False
|
|
else:
|
|
event.button.label = "Show key"
|
|
self.query_one("#api-key-input").password = True
|
|
|
|
|
|
class SkipCategoriesManager(Vertical):
|
|
"""Manager for skip categories, allows to select which categories to skip."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Skip Categories", classes="title")
|
|
yield Label("Select the categories you want to skip", classes="subtitle")
|
|
skip_categories_parsed = []
|
|
for i in skip_categories:
|
|
name, value = i
|
|
if value in self.config.skip_categories:
|
|
skip_categories_parsed.append((name, value, True))
|
|
else:
|
|
skip_categories_parsed.append((name, value, False))
|
|
# print(skip_categories_parsed)
|
|
yield SelectionList(*skip_categories_parsed, id="skip-categories-compact-list")
|
|
|
|
@on(SelectionList.SelectedChanged, "#skip-categories-compact-list")
|
|
def changed_skip_categories(self, event: SelectionList.SelectedChanged):
|
|
self.config.skip_categories = event.selection_list.selected
|
|
|
|
|
|
class SkipCountTrackingManager(Vertical):
|
|
"""Manager for skip count tracking, allows to enable/disable skip count tracking."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Skip count tracking", classes="title")
|
|
yield Label(
|
|
"This feature tracks which segments you have skipped to let users know how much their submission has helped others and used as a metric along with upvotes to ensure that spam doesn't get into the database. The program sends a message to the sponsor block server each time you skip a segment. Hopefully most people don't change this setting so that the view numbers are accurate. :)",
|
|
classes="subtitle", id="skip-count-tracking-subtitle")
|
|
yield Checkbox(value=self.config.skip_count_tracking, id="skip-count-tracking-switch",
|
|
label="Enable skip count tracking")
|
|
|
|
@on(Checkbox.Changed, "#skip-count-tracking-switch")
|
|
def changed_skip_tracking(self, event: Checkbox.Changed):
|
|
self.config.skip_count_tracking = event.checkbox.value
|
|
|
|
|
|
class AdSkipMuteManager(Vertical):
|
|
"""Manager for ad skip/mute, allows to enable/disable ad skip/mute."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Skip/Mute ads", classes="title")
|
|
yield Label(
|
|
"This feature allows you to automatically mute and/or skip native YouTube ads. Skipping ads only works if that ad shows the 'Skip Ad' button, if it doesn't then it will only be able to be muted.",
|
|
classes="subtitle", id="skip-count-tracking-subtitle")
|
|
with Horizontal(id="ad-skip-mute-container"):
|
|
yield Checkbox(value=self.config.mute_ads, id="mute-ads-switch",
|
|
label="Enable skipping ads")
|
|
yield Checkbox(value=self.config.skip_ads, id="skip-ads-switch",
|
|
label="Enable muting ads")
|
|
|
|
@on(Checkbox.Changed, "#mute-ads-switch")
|
|
def changed_mute(self, event: Checkbox.Changed):
|
|
self.config.mute_ads = event.checkbox.value
|
|
|
|
@on(Checkbox.Changed, "#skip-ads-switch")
|
|
def changed_skip(self, event: Checkbox.Changed):
|
|
self.config.skip_ads = event.checkbox.value
|
|
|
|
|
|
class ChannelWhitelistManager(Vertical):
|
|
"""Manager for channel whitelist, allows to add/remove channels from the whitelist."""
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Label("Channel Whitelist", classes="title")
|
|
yield Label(
|
|
"This feature allows to whitelist channels from being skipped. This feature is automatically disabled when no channels have been specified.",
|
|
classes="subtitle", id="channel-whitelist-subtitle")
|
|
yield Label(":warning: [#FF0000]You need to set your YouTube Api Key in order to use this feature",
|
|
id="warning-no-key")
|
|
with Horizontal(id="add-channel-button-container"):
|
|
yield Button("Add Channel", id="add-channel", classes="button-100")
|
|
for channel in self.config.channel_whitelist:
|
|
yield Channel(channel)
|
|
|
|
def on_mount(self) -> None:
|
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(self.config.channel_whitelist)
|
|
def new_channel(self, channel: tuple) -> None:
|
|
if channel:
|
|
channel_dict = {
|
|
"id": channel[0],
|
|
"name": channel[1],
|
|
}
|
|
self.config.channel_whitelist.append(channel_dict)
|
|
channel_widget = Channel(channel_dict)
|
|
self.mount(channel_widget)
|
|
channel_widget.focus(scroll_visible=True)
|
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
|
|
self.config.channel_whitelist)
|
|
|
|
@on(Button.Pressed, "#element-remove")
|
|
def remove_channel(self, event: Button.Pressed):
|
|
channel_to_remove: Element = event.button.parent
|
|
self.config.channel_whitelist.remove(channel_to_remove.element_data)
|
|
channel_to_remove.remove()
|
|
self.app.query_one("#warning-no-key").display = (not self.config.apikey) and bool(
|
|
self.config.channel_whitelist)
|
|
|
|
@on(Button.Pressed, "#add-channel")
|
|
def add_channel(self, event: Button.Pressed):
|
|
self.app.push_screen(AddChannel(self.config), callback=self.new_channel)
|
|
|
|
|
|
class iSponsorBlockTVSetupMainScreen(Screen):
|
|
"""Making this a separate screen to avoid a bug: https://github.com/Textualize/textual/issues/3221"""
|
|
TITLE = "iSponsorBlockTV"
|
|
SUB_TITLE = "Setup Wizard"
|
|
BINDINGS = [
|
|
("q,ctrl+c", "exit_modal", "Exit"),
|
|
("s", "save", "Save")
|
|
]
|
|
AUTO_FOCUS = None
|
|
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.dark = True
|
|
self.config = config
|
|
self.initial_config = copy.deepcopy(config)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield Footer()
|
|
with ScrollableContainer(id="setup-wizard"):
|
|
yield DevicesManager(config=self.config, id="devices-manager", classes="container")
|
|
yield SkipCategoriesManager(config=self.config, id="skip-categories-manager", classes="container")
|
|
yield SkipCountTrackingManager(config=self.config, id="count-segments-manager", classes="container")
|
|
yield AdSkipMuteManager(config=self.config, id="ad-skip-mute-manager", classes="container")
|
|
yield ChannelWhitelistManager(config=self.config, id="channel-whitelist-manager", classes="container")
|
|
yield ApiKeyManager(config=self.config, id="api-key-manager", classes="container")
|
|
|
|
def on_mount(self) -> None:
|
|
if self.check_for_old_config_entries():
|
|
self.app.push_screen(MigrationScreen())
|
|
pass
|
|
|
|
def action_save(self) -> None:
|
|
self.config.save()
|
|
self.initial_config = copy.deepcopy(self.config)
|
|
|
|
def action_exit_modal(self) -> None:
|
|
if self.config != self.initial_config:
|
|
self.app.push_screen(ExitScreen())
|
|
else: # No changes were made
|
|
self.app.exit()
|
|
|
|
def check_for_old_config_entries(self) -> bool:
|
|
if hasattr(self.config, "atvs"):
|
|
return True
|
|
return False
|
|
|
|
@on(Input.Changed, "#api-key-input")
|
|
def changed_api_key(self, event: Input.Changed):
|
|
print("HIIII")
|
|
try: # ChannelWhitelist might not be mounted
|
|
# Show if no api key is set and at least one channel is in the whitelist
|
|
self.app.query_one("#warning-no-key").display = (not event.input.value) and self.config.channel_whitelist
|
|
except:
|
|
pass
|
|
|
|
class iSponsorBlockTVSetup(App):
|
|
CSS_PATH = "setup-wizard-style.tcss" # tcss is the recommended extension for textual css files
|
|
# Bindings for the whole app here, so they are available in all screens
|
|
BINDINGS = [
|
|
("q,ctrl+c", "exit_modal", "Exit"),
|
|
("s", "save", "Save")
|
|
]
|
|
|
|
def __init__(self, config, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.config = config
|
|
self.main_screen = iSponsorBlockTVSetupMainScreen(config=self.config)
|
|
|
|
def on_mount(self) -> None:
|
|
self.push_screen(self.main_screen)
|
|
|
|
def action_save(self) -> None:
|
|
self.main_screen.action_save()
|
|
|
|
def action_exit_modal(self) -> None:
|
|
self.main_screen.action_exit_modal()
|
|
|
|
|
|
def main(config):
|
|
app = iSponsorBlockTVSetup(config)
|
|
app.run() |