Add ViewManager

- Updated to be more self-contained and work better with unsupported skins
- TODO: Add support for setting default sort order and sort direction

Fix content type not being set to episodes

- Fix #586, #589

Update for restructure of xbmc_plugin

- Only set view mode if directory items successfully added

Fix preselect on view_manager view lists

Update to match new setup wizard

Update for reorganised/renamed constants fca610c

Update for updated XbmcContext.apply_content 8a8247a

Update for new localize and logging methods

Update to fix setting view mode not working if container is still updating

Update to handle sort method and order and workaround #1243

Update to handle localised sort order #1309
This commit is contained in:
MoojMidge 2025-09-03 12:58:31 +09:00
parent 2c701f800c
commit 6affa686e2
12 changed files with 457 additions and 2 deletions

View file

@ -144,6 +144,7 @@ class AbstractProvider(object):
if last_run and last_run > 1:
self.pre_run_wizard_step(provider=self, context=context)
wizard_steps = self.get_wizard_steps()
wizard_steps.extend(ui.get_view_manager().get_wizard_steps())
step = 0
steps = len(wizard_steps)

View file

@ -186,6 +186,8 @@ PAGE = 'page'
PLAYLIST_IDS = 'playlist_ids'
SCREENSAVER = 'screensaver'
SEEK = 'seek'
SORT_DIR = 'sort_dir'
SORT_METHOD = 'sort_method'
START = 'start'
VIDEO_IDS = 'video_ids'
@ -346,6 +348,8 @@ __all__ = (
'PLAYLIST_IDS',
'SCREENSAVER',
'SEEK',
'SORT_DIR',
'SORT_METHOD',
'START',
'VIDEO_IDS',

View file

@ -11,8 +11,8 @@
from __future__ import absolute_import, division, unicode_literals
VIDEO_CONTENT = 'videos'
LIST_CONTENT = 'files'
VIDEO_CONTENT = 'episodes'
LIST_CONTENT = 'default'
COMMENTS = 'comments'
HISTORY = 'history'

View file

@ -14,6 +14,7 @@ import sys
from . import const_content_types as CONTENT
from ..compatibility import (
xbmc,
xbmcplugin,
)
@ -77,12 +78,31 @@ methods = [
('VIDEO_ORIGINAL_TITLE', 20376, 57),
('VIDEO_ORIGINAL_TITLE_IGNORE_THE', 20376, None),
]
SORT_ID_MAPPING = {}
SORT = sys.modules[__name__]
name = label_id = sort_by = sort_method = None
for name, label_id, sort_by in methods:
sort_method = getattr(xbmcplugin, 'SORT_METHOD_' + name, 0)
setattr(SORT, name, sort_method)
if sort_by is not None:
SORT_ID_MAPPING.update((
(name, sort_by),
(xbmc.getLocalizedString(label_id), sort_by),
(sort_method, sort_by if sort_method else 0),
))
SORT_ID_MAPPING.update((
(CONTENT.VIDEO_CONTENT.join(('__', '__')), SORT.UNSORTED),
(CONTENT.LIST_CONTENT.join(('__', '__')), SORT.LABEL),
(CONTENT.COMMENTS.join(('__', '__')), SORT.CHANNEL),
(CONTENT.HISTORY.join(('__', '__')), SORT.LASTPLAYED),
))
SORT_DIR = {
xbmc.getLocalizedString(584): 'ascending',
xbmc.getLocalizedString(585): 'descending',
}
# Label mask token details:
# https://github.com/xbmc/xbmc/blob/master/xbmc/utils/LabelFormatter.cpp#L33-L105
@ -179,6 +199,7 @@ COMMENTS_CONTENT_SIMPLE = (
del (
sys,
CONTENT,
xbmc,
xbmcplugin,
methods,
SORT,

View file

@ -61,6 +61,8 @@ from ..constants import (
PLAY_USING,
SCREENSAVER,
SEEK,
SORT_DIR,
SORT_METHOD,
START,
SUBSCRIPTION_ID,
VIDEO_ID,
@ -169,6 +171,8 @@ class AbstractContext(object):
'q',
'rating',
'reload_path',
SORT_DIR,
SORT_METHOD,
'search_type',
SUBSCRIPTION_ID,
'uri',

View file

@ -704,6 +704,7 @@ class XbmcContext(AbstractContext):
path=self.get_path())
if content_type != 'default':
xbmcplugin.setContent(self._plugin_handle, content_type)
ui.get_view_manager().set_view_mode(content_type)
if category_label is None:
category_label = self.get_param('category_label')

View file

@ -34,6 +34,8 @@ from ...constants import (
REFRESH_CONTAINER,
RELOAD_ACCESS_MANAGER,
REROUTE_PATH,
SORT_DIR,
SORT_METHOD,
SYNC_LISTITEM,
TRAKT_PAUSE_FLAG,
VIDEO_ID,
@ -417,7 +419,31 @@ class XbmcPlugin(AbstractPlugin):
container = ui.get_property(CONTAINER_ID)
position = ui.get_property(CONTAINER_POSITION)
# set alternative view mode
view_manager = ui.get_view_manager()
if view_manager.is_override_view_enabled():
post_run_actions.append((
view_manager.apply_view_mode,
{
'context': context,
},
))
if is_same_path:
sort_method = kwargs.get(SORT_METHOD)
sort_dir = kwargs.get(SORT_DIR)
if sort_method and sort_dir:
post_run_actions.append((
view_manager.apply_sort_method,
{
'context': context,
SORT_METHOD: sort_method,
SORT_DIR: sort_dir,
CONTAINER_POSITION: position if forced else None,
},
))
position = None
if (container and position
and (forced or position == 'current')
and (not played_video_id or route)):

View file

@ -17,6 +17,8 @@ from .constants import (
CHECK_SETTINGS,
FOLDER_URI,
PATHS,
SORT_DIR,
SORT_METHOD,
)
from .context import XbmcContext
from .debug import Profiler
@ -103,6 +105,20 @@ def run(context=_context,
refresh = context.refresh_requested(force=True, off=True, params=params)
new_params['refresh'] = refresh if refresh else 0
sort_method = (
params.get(SORT_METHOD)
or ui.get_infolabel('Container.SortMethod')
)
if sort_method:
new_kwargs[SORT_METHOD] = sort_method
sort_dir = (
params.get(SORT_DIR)
or ui.get_infolabel('Container.SortOrder')
)
if sort_dir:
new_kwargs[SORT_DIR] = sort_dir
if new_params:
context.set_params(**new_params)

View file

@ -22,6 +22,9 @@ class AbstractContextUI(object):
message_template=None):
raise NotImplementedError()
def get_view_manager(self):
raise NotImplementedError()
@staticmethod
def on_keyboard_input(title, default='', hidden=False):
raise NotImplementedError()

View file

@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""
Copyright (C) 2014-2016 bromix (plugin.video.youtube)
Copyright (C) 2016-2025 plugin.video.youtube
SPDX-License-Identifier: GPL-2.0-only
See LICENSES/GPL-2.0-only for more information.
"""
from __future__ import absolute_import, division, unicode_literals
from ... import logging
from ...compatibility import xbmc
from ...constants import (
CONTAINER_POSITION,
CONTENT,
SORT,
SORT_DIR,
SORT_METHOD,
)
class ViewManager(object):
log = logging.getLogger(__name__)
SETTINGS = {
'override': 'kodion.view.override', # (bool)
'view_default': 'kodion.view.default', # (int)
'view_type': 'kodion.view.{0}', # (int)
}
SUPPORTED_TYPES_MAP = {
CONTENT.LIST_CONTENT: 'default',
CONTENT.VIDEO_CONTENT: 'episodes',
}
STRING_MAP = {
'prompt': 30777,
'unsupported_skin': 10109,
'supported_skin': 14240,
'albums': 30035,
'artists': 30034,
'default': 30027,
'episodes': 30028,
'movies': 30029,
'songs': 30033,
'tvshows': 30032,
}
SKIN_DATA = {
'skin.confluence': {
'default': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500}
),
'movies': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 504},
{'name': 'Media info 2', 'id': 503}
),
'episodes': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 504},
{'name': 'Media info 2', 'id': 503}
),
'tvshows': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Poster', 'id': 500},
{'name': 'Wide', 'id': 505},
{'name': 'Media info', 'id': 504},
{'name': 'Media info 2', 'id': 503},
{'name': 'Fanart', 'id': 508}
),
'musicvideos': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 504},
{'name': 'Media info 2', 'id': 503}
),
'songs': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 506}
),
'albums': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 506}
),
'artists': (
{'name': 'List', 'id': 50},
{'name': 'Big List', 'id': 51},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Media info', 'id': 506}
)
},
'skin.aeon.nox.5': {
'default': (
{'name': 'List', 'id': 50},
{'name': 'Episodes', 'id': 502},
{'name': 'LowList', 'id': 501},
{'name': 'BannerWall', 'id': 58},
{'name': 'Shift', 'id': 57},
{'name': 'Posters', 'id': 56},
{'name': 'ShowCase', 'id': 53},
{'name': 'Landscape', 'id': 52},
{'name': 'InfoWall', 'id': 51}
)
},
'skin.xperience1080+': {
'default': (
{'name': 'List', 'id': 50},
{'name': 'Thumbnail', 'id': 500},
),
'episodes': (
{'name': 'List', 'id': 50},
{'name': 'Info list', 'id': 52},
{'name': 'Fanart', 'id': 502},
{'name': 'Landscape', 'id': 54},
{'name': 'Poster', 'id': 55},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Banner', 'id': 60}
),
},
'skin.xperience1080': {
'default': (
{'name': 'List', 'id': 50},
{'name': 'Thumbnail', 'id': 500},
),
'episodes': (
{'name': 'List', 'id': 50},
{'name': 'Info list', 'id': 52},
{'name': 'Fanart', 'id': 502},
{'name': 'Landscape', 'id': 54},
{'name': 'Poster', 'id': 55},
{'name': 'Thumbnail', 'id': 500},
{'name': 'Banner', 'id': 60}
),
},
'skin.estuary': {
'default': (
{'name': 'IconWall', 'id': 52},
{'name': 'WideList', 'id': 55},
),
'videos': (
{'name': 'Shift', 'id': 53},
{'name': 'InfoWall', 'id': 54},
{'name': 'WideList', 'id': 55},
{'name': 'Wall', 'id': 500},
),
'episodes': (
{'name': 'InfoWall', 'id': 54},
{'name': 'Wall', 'id': 500},
{'name': 'WideList', 'id': 55},
)
}
}
def __init__(self, context):
self._context = context
self._view_mode = None
def is_override_view_enabled(self):
return self._context.get_settings().get_bool(self.SETTINGS['override'])
def get_wizard_steps(self):
return (self.run,)
def run(self, context, step, steps, **_kwargs):
localize = context.localize
skin_id = xbmc.getSkinDir()
if skin_id in self.SKIN_DATA:
status = localize(self.STRING_MAP['supported_skin'])
else:
status = localize(self.STRING_MAP['unsupported_skin'])
prompt_text = localize(self.STRING_MAP['prompt'], (skin_id, status))
step += 1
if context.get_ui().on_yes_no_input(
'{youtube} - {setup_wizard} ({step}/{steps})'.format(
youtube=localize('youtube'),
setup_wizard=localize('setup_wizard'),
step=step,
steps=steps,
),
localize('setup_wizard.prompt.x', prompt_text)
):
for view_type in self.SUPPORTED_TYPES_MAP:
self.update_view_mode(skin_id, view_type)
return step
def get_view_mode(self):
if self._view_mode is None:
self.set_view_mode()
return self._view_mode
def set_view_mode(self, view_type='default'):
settings = self._context.get_settings()
default = settings.get_int(self.SETTINGS['view_default'], 50)
if view_type == 'default':
view_mode = default
else:
view_type = self.SUPPORTED_TYPES_MAP.get(view_type, 'default')
view_mode = settings.get_int(
self.SETTINGS['view_type'].format(view_type), default
)
self._view_mode = view_mode
def update_view_mode(self, skin_id, view_type='default'):
view_id = -1
settings = self._context.get_settings()
ui = self._context.get_ui()
content_type = self.SUPPORTED_TYPES_MAP[view_type]
if content_type not in self.STRING_MAP:
self.log.warning('Unsupported content type: %r', content_type)
return False
title = self._context.localize(self.STRING_MAP[content_type])
view_setting = self.SETTINGS['view_type'].format(content_type)
current_value = settings.get_int(view_setting)
if current_value == -1:
self.log.warning('No setting for content type: %r', content_type)
return False
skin_data = self.SKIN_DATA.get(skin_id, {})
view_type_data = skin_data.get(view_type) or skin_data.get(content_type)
if view_type_data:
items = []
preselect = -1
for view_data in view_type_data:
view_id = view_data['id']
items.append((view_data['name'], view_id))
if view_id == current_value:
preselect = len(items) - 1
view_id = ui.on_select(title, items, preselect=preselect)
else:
self.log.warning('Unsupported view: %r', view_type)
if view_id == -1:
result, view_id = ui.on_numeric_input(title, current_value)
if not result:
return False
if view_id > -1:
settings.set_int(view_setting, view_id)
settings.set_bool(self.SETTINGS['override'], True)
return True
return False
def apply_view_mode(self, context):
view_mode = self.get_view_mode()
if view_mode is None:
return
self.log.debug('Applying view mode: %r', view_mode)
context.execute('Container.SetViewMode(%s)' % view_mode)
@classmethod
def apply_sort_method(cls, context, **kwargs):
execute = context.execute
get_infobool = xbmc.getCondVisibility
sort_method = (
kwargs.get(SORT_METHOD)
or CONTENT.VIDEO_CONTENT.join(('__', '__'))
)
sort_id = SORT.SORT_ID_MAPPING.get(sort_method)
if sort_id is None:
cls.log.warning('Unknown sort method: %r', sort_method)
return
sort_dir = kwargs.get(SORT_DIR)
_sort_dir = SORT.SORT_DIR.get(sort_dir)
if _sort_dir is None:
cls.log.warning('Invalid sort direction: %r', sort_dir)
return
position = kwargs.get(CONTAINER_POSITION)
if position is not None:
context.get_ui().focus_container(position=position)
# Workaround for Container.SetSortMethod failing for some sort methods
num_attempts = 0
while num_attempts < 4:
# Workaround for Container.SetSortMethod(0) being a noop
# https://github.com/xbmc/xbmc/blob/7e1a55cb861342cd9062745161d88aca08dcead1/xbmc/windows/GUIMediaWindow.cpp#L502
if sort_id == 0:
# Sort by track number to reset sort order to default order
if not num_attempts % 2:
_sort_method = 'TRACKNUM'
_sort_id = SORT.SORT_ID_MAPPING.get(_sort_method)
sort_action = 'Container.SetSortMethod(%s)' % _sort_id
# Then switch to previous sort method which is default/unsorted
# as per the order set in XbmcContext.apply_content
else:
_sort_method = 'UNSORTED'
_sort_id = SORT.SORT_ID_MAPPING.get(_sort_method)
sort_action = 'Container.PreviousSortMethod'
else:
_sort_method = sort_method
_sort_id = sort_id
sort_action = 'Container.SetSortMethod(%s)' % _sort_id
cls.log.debug('Applying sort method: {method!r} ({id})',
method=_sort_method,
id=_sort_id)
execute(sort_action)
context.sleep(0.1)
if not get_infobool('Container.SortDirection(%s)' % _sort_dir):
cls.log.debug('Applying sort direction: %r', sort_dir)
# This builtin should be Container.SortDirection but has been
# broken since Kodi v16
# https://github.com/xbmc/xbmc/commit/ac870b64b16dfd0fc2bd0496c14529cf6d563f41
execute('Container.SetSortDirection')
context.sleep(0.1)
num_attempts += 1
if get_infobool('Container.SortMethod(%s)' % sort_id):
break
else:
cls.log.warning('Unable to apply sorting:'
' {sort_method!r} ({sort_id}) {sort_dir!r}',
sort_method=sort_method,
sort_id=sort_id,
sort_dir=sort_dir)

View file

@ -12,6 +12,7 @@ from __future__ import absolute_import, division, unicode_literals
from weakref import proxy
from .view_manager import ViewManager
from ..abstract_context_ui import AbstractContextUI
from ... import logging
from ...compatibility import string_type, xbmc, xbmcgui
@ -47,6 +48,7 @@ class XbmcContextUI(AbstractContextUI):
def __init__(self, context):
super(XbmcContextUI, self).__init__()
self._context = context
self._view_manager = None
def create_progress_dialog(self,
heading,
@ -77,6 +79,12 @@ class XbmcContextUI(AbstractContextUI):
),
)
def get_view_manager(self):
if self._view_manager is None:
self._view_manager = ViewManager(self._context)
return self._view_manager
@staticmethod
def on_keyboard_input(title, default='', hidden=False):
# Starting with Gotham (13.X > ...)

View file

@ -896,6 +896,35 @@
</constraints>
<control format="string" type="spinner"/>
</setting>
<setting id="kodion.view.override" type="boolean" label="30026" help="">
<level>0</level>
<default>false</default>
<control type="toggle"/>
</setting>
<setting id="kodion.view.default" type="integer" parent="kodion.view.override" label="30027" help="">
<level>0</level>
<default>55</default>
<dependencies>
<dependency type="enable">
<condition setting="kodion.view.override" operator="is">true</condition>
</dependency>
</dependencies>
<control format="integer" type="edit">
<heading>30027</heading>
</control>
</setting>
<setting id="kodion.view.episodes" type="integer" parent="kodion.view.override" label="30028" help="">
<level>0</level>
<default>55</default>
<dependencies>
<dependency type="enable">
<condition setting="kodion.view.override" operator="is">true</condition>
</dependency>
</dependencies>
<control format="integer" type="edit">
<heading>30028</heading>
</control>
</setting>
</group>
<group id="regional" label="14222">
<setting id="youtube.language_region.configure" type="action" label="30527" help="">